import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:just_audio/just_audio.dart'; import 'package:video_player/video_player.dart'; import 'package:shared_preferences/shared_preferences.dart'; String get immichBaseUrl => AppSettings.immichBaseUrl; String get immichApiKey => AppSettings.apiKey; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await AppSettings.load(); runApp(const ImmichTvApp()); } class AppSettings { static const String defaultServerIp = '192.168.1.29'; static const String defaultPort = '2283'; static const String defaultApiKey = 'COLLE_TA_CLE_API_ICI'; static const int defaultAutoDelaySeconds = 10; static String serverIp = defaultServerIp; static String port = defaultPort; static String apiKey = defaultApiKey; static int autoDelaySeconds = defaultAutoDelaySeconds; static String get immichBaseUrl { final cleanIp = serverIp.trim().replaceAll('http://', '').replaceAll('https://', '').replaceAll('/api', '').replaceAll('/', ''); final cleanPort = port.trim(); return 'http://$cleanIp:$cleanPort/api'; } static Future load() async { final prefs = await SharedPreferences.getInstance(); serverIp = prefs.getString('immich_server_ip') ?? defaultServerIp; port = prefs.getString('immich_port') ?? defaultPort; apiKey = prefs.getString('immich_api_key') ?? defaultApiKey; autoDelaySeconds = prefs.getInt('auto_delay_seconds') ?? defaultAutoDelaySeconds; if (autoDelaySeconds < 3) { autoDelaySeconds = 3; } if (autoDelaySeconds > 300) { autoDelaySeconds = 300; } } static Future save({ required String newServerIp, required String newPort, required String newApiKey, required int newAutoDelaySeconds, }) async { final prefs = await SharedPreferences.getInstance(); serverIp = newServerIp.trim().replaceAll('http://', '').replaceAll('https://', '').replaceAll('/api', '').replaceAll('/', ''); port = newPort.trim(); apiKey = newApiKey.trim(); autoDelaySeconds = newAutoDelaySeconds.clamp(3, 300); await prefs.setString('immich_server_ip', serverIp); await prefs.setString('immich_port', port); await prefs.setString('immich_api_key', apiKey); await prefs.setInt('auto_delay_seconds', autoDelaySeconds); } } class ImmichTvApp extends StatelessWidget { const ImmichTvApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Imedia Photos', debugShowCheckedModeBanner: false, theme: ThemeData.dark(useMaterial3: true), home: const HomeShell(), ); } } class SettingsBackHandler { static bool Function()? handleBack; static bool run() { final handler = handleBack; if (handler == null) { return false; } return handler(); } } class AppColors { static const background = Color(0xFF020409); static const sidebar = Color(0xFF080D16); static const card = Color(0xFF121722); static const selected = Color(0xFF234A91); static const selectedBorder = Color(0xFF4C8DFF); static const mutedText = Color(0xFFB8BFCC); } enum AppSection { photos, videos, explorer, surprise, settings } class ImmichPerson { final String id; final String name; final bool isHidden; ImmichPerson({required this.id, required this.name, required this.isHidden}); bool get hasName => name.trim().isNotEmpty; String displayName(int index) => hasName ? name.trim() : 'Sans nom ${index + 1}'; factory ImmichPerson.fromJson(Map json) => ImmichPerson( id: json['id'] ?? '', name: json['name'] ?? '', isHidden: json['isHidden'] ?? false, ); } class PersonWithCount { final ImmichPerson person; final int photoCount; final ImmichAsset? firstAsset; PersonWithCount({required this.person, required this.photoCount, required this.firstAsset}); } class ImmichAsset { final String id; final String fileName; final String mediaType; final bool isTrashed; final String localDateTime; final String city; final String state; final String country; final double? latitude; final double? longitude; ImmichAsset({ required this.id, required this.fileName, required this.mediaType, required this.isTrashed, required this.localDateTime, required this.city, required this.state, required this.country, required this.latitude, required this.longitude, }); factory ImmichAsset.fromJson(Map json) { final exifInfo = json['exifInfo'] is Map ? json['exifInfo'] as Map : {}; String clean(dynamic value) { if (value == null) return ''; final text = value.toString().trim(); if (text == 'null') return ''; return text; } double? cleanDouble(dynamic value) { if (value == null) return null; if (value is num) return value.toDouble(); return double.tryParse(value.toString()); } final lat = cleanDouble( exifInfo['latitude'] ?? exifInfo['lat'] ?? json['latitude'] ?? json['lat'], ); final lon = cleanDouble( exifInfo['longitude'] ?? exifInfo['lon'] ?? exifInfo['lng'] ?? json['longitude'] ?? json['lon'] ?? json['lng'], ); return ImmichAsset( id: clean(json['id']), fileName: clean(json['originalFileName']), mediaType: clean(json['type']).isEmpty ? 'UNKNOWN' : clean(json['type']), isTrashed: json['isTrashed'] == true, localDateTime: clean( json['localDateTime'] ?? exifInfo['dateTimeOriginal'] ?? json['fileCreatedAt'] ?? json['createdAt'], ), city: clean(exifInfo['city'] ?? json['city']), state: clean(exifInfo['state'] ?? json['state']), country: clean(exifInfo['country'] ?? json['country']), latitude: lat, longitude: lon, ); } String get locationLabel { final parts = [city, state, country] .where((part) => part.trim().isNotEmpty) .toList(); if (parts.isNotEmpty) { return parts.join(', '); } if (latitude != null && longitude != null) { return 'GPS ${latitude!.toStringAsFixed(4)}, ${longitude!.toStringAsFixed(4)}'; } return 'Lieu inconnu'; } String get dateLabel { final rawDate = localDateTime.trim(); if (rawDate.isEmpty) { return 'Date inconnue'; } final parsedDate = DateTime.tryParse(rawDate); if (parsedDate == null) { return rawDate; } final day = parsedDate.day.toString().padLeft(2, '0'); final month = parsedDate.month.toString().padLeft(2, '0'); final year = parsedDate.year.toString(); return '$day/$month/$year'; } } class SearchPageResult { final List assets; final String? nextPage; SearchPageResult({required this.assets, required this.nextPage}); } class ImmichApi { static Map get headers => {'x-api-key': AppSettings.apiKey}; static final Map> _imageBytesCache = {}; static final Map> _assetDetailsCache = {}; static void clearCaches() { _imageBytesCache.clear(); _assetDetailsCache.clear(); } static Future> getAllPeople() async { final all = []; var page = 1; var hasNext = true; while (hasNext) { final uri = Uri.parse('$immichBaseUrl/people?page=$page&size=100&withHidden=false'); final res = await http.get(uri, headers: headers); if (res.statusCode != 200) throw Exception('Erreur people: ${res.statusCode} ${res.body}'); final data = jsonDecode(res.body) as Map; final people = (data['people'] as List? ?? []) .map((e) => ImmichPerson.fromJson(e as Map)) .where((p) => p.id.isNotEmpty && !p.isHidden) .toList(); all.addAll(people); hasNext = data['hasNextPage'] == true; page++; if (page > 50) break; } all.sort((a,b){ if (a.hasName && !b.hasName) return -1; if (!a.hasName && b.hasName) return 1; return a.name.toLowerCase().compareTo(b.name.toLowerCase()); }); return all; } static Future> getPeopleWithPhotoCounts({void Function(String)? onProgress}) async { final people = await getAllPeople(); final result = []; const batch = 6; for (var i = 0; i < people.length; i += batch) { onProgress?.call('Analyse ${i + 1}/${people.length} personnes...'); final slice = people.skip(i).take(batch).toList(); final checked = await Future.wait(slice.map((p) async { final first = await searchPhotosForPerson(p.id, page: 1, size: 1); if (first.assets.isEmpty) return PersonWithCount(person: p, photoCount: 0, firstAsset: null); final count = await countPhotosForPerson(p.id); return PersonWithCount(person: p, photoCount: count, firstAsset: first.assets.first); })); result.addAll(checked.where((x) => x.photoCount > 0)); } result.sort((a,b) => b.photoCount.compareTo(a.photoCount)); return result; } static Future countPhotosForPerson(String personId) async { var total=0, page=1; String? next='1'; while (next != null) { final r = await searchPhotosForPerson(personId, page: page, size: 250); total += r.assets.length; next = r.nextPage; if (next == null || next.isEmpty) break; page = int.tryParse(next) ?? page + 1; if (page > 100) break; } return total; } static Future> getAllTimelinePhotos({int maxPages = 9999}) async { final all=[]; var page=1; String? next='1'; while (next != null && page <= maxPages) { final r = await searchAllPhotos(page: page, size: 250); all.addAll(r.assets); next = r.nextPage; if (next == null || next.isEmpty) break; page = int.tryParse(next) ?? page+1; } return all; } static Future> getRandomTimelinePhotos({int maxPages = 9999}) async { final photos = await getAllTimelinePhotos(maxPages: maxPages); photos.shuffle(Random()); return photos; } static Future> getAllTimelineVideos({ int maxPages = 9999, }) async { final allAssets = []; var page = 1; const size = 250; String? nextPage = '1'; while (nextPage != null && page <= maxPages) { final pageResult = await searchAllVideos(page: page, size: size); allAssets.addAll(pageResult.assets); nextPage = pageResult.nextPage; if (nextPage == null || nextPage.isEmpty) { break; } page = int.tryParse(nextPage) ?? page + 1; } return allAssets; } static Future searchAllVideos({ required int page, required int size, }) async { final data = await _postSearchMetadata({ 'type': 'VIDEO', 'size': size, 'page': page, 'order': 'desc', 'withArchived': false, 'withDeleted': false, 'withPeople': false, 'withStacked': true, }); return _parseSearchPageForType(data, 'VIDEO'); } static String assetVideoPlaybackUrl(String assetId) { return '$immichBaseUrl/assets/$assetId/video/playback'; } static Future> getPhotosForPerson(String personId) async { final all=[]; var page=1; String? next='1'; while (next != null) { final r=await searchPhotosForPerson(personId, page: page, size: 250); debugPrint('IMMICH_DEBUG person=$personId page=$page imageAssets=${r.assets.length}'); all.addAll(r.assets); next=r.nextPage; if (next == null || next.isEmpty) break; page=int.tryParse(next) ?? page+1; if (page > 100) break; } debugPrint('IMMICH_DEBUG person=$personId totalPhotos=${all.length}'); return all; } static Future getAssetDetails(String assetId) { if (_assetDetailsCache.containsKey(assetId)) { return _assetDetailsCache[assetId]!; } final future = _getAssetDetailsNoCache(assetId); _assetDetailsCache[assetId] = future; return future; } static Future _getAssetDetailsNoCache(String assetId) async { final uri = Uri.parse('$immichBaseUrl/assets/$assetId'); final response = await http.get(uri, headers: headers); if (response.statusCode != 200) { throw Exception('Erreur détail asset: ${response.statusCode}'); } final asset = ImmichAsset.fromJson( jsonDecode(response.body) as Map, ); debugPrint( 'IMMICH_LOCATION asset=$assetId location="${asset.locationLabel}" date="${asset.dateLabel}"', ); return asset; } static Future searchAllPhotos({required int page, required int size}) async { return _parseSearchPage(await _postSearchMetadata({ 'type':'IMAGE','size':size,'page':page,'order':'desc', 'withArchived':false,'withDeleted':false,'withPeople':false,'withStacked':true, })); } static Future searchPhotosForPerson(String personId,{required int page,required int size}) async { return _parseSearchPage(await _postSearchMetadata({ 'personIds':[personId],'type':'IMAGE','size':size,'page':page,'order':'desc', 'withArchived':true,'withDeleted':false,'withPeople':true,'withStacked':true, })); } static Future> _postSearchMetadata(Map body) async { final res = await http.post(Uri.parse('$immichBaseUrl/search/metadata'), headers:{...headers,'Content-Type':'application/json'}, body:jsonEncode(body)); if (res.statusCode != 200 && res.statusCode != 201) throw Exception('Erreur search: ${res.statusCode} ${res.body}'); return jsonDecode(res.body) as Map; } static SearchPageResult _parseSearchPage(Map data) { return _parseSearchPageForType(data, 'IMAGE'); } static SearchPageResult _parseSearchPageForType( Map data, String mediaType, ) { final items = _extractAssetItems(data); final assets = items .map((item) => ImmichAsset.fromJson(item as Map)) .where((asset) => asset.id.isNotEmpty) .where((asset) => asset.mediaType == mediaType) .where((asset) => !asset.isTrashed) .toList(); return SearchPageResult( assets: assets, nextPage: _extractNextPage(data), ); } static List _extractAssetItems(Map data) { if (data['assets'] is Map) { final a = data['assets'] as Map; if (a['items'] is List) return a['items'] as List; } if (data['items'] is List) return data['items'] as List; return []; } static String? _extractNextPage(Map data) { final v = data['assets'] is Map ? (data['assets'] as Map)['nextPage'] : data['nextPage']; return v?.toString(); } static String personThumbnailUrl(String id) => '$immichBaseUrl/people/$id/thumbnail'; static String assetPreviewUrl(String id) => '$immichBaseUrl/assets/$id/thumbnail?size=preview'; static String assetThumbUrl(String id) => '$immichBaseUrl/assets/$id/thumbnail?size=thumbnail'; static Future fetchImageBytes(String url) { if (!_imageBytesCache.containsKey(url)) { // La Mi Box n'a pas une RAM infinie, malgré son arrogance HDMI. // On garde un cache raisonnable pour éviter de remplir la mémoire avec 13 000 miniatures. if (_imageBytesCache.length > 180) { _imageBytesCache.remove(_imageBytesCache.keys.first); } _imageBytesCache[url] = _fetchImageBytesNoCache(url); } return _imageBytesCache[url]!; } static Future _fetchImageBytesNoCache(String url) async { final res = await http.get(Uri.parse(url), headers: headers).timeout(const Duration(seconds: 20)); // Les thumbnails peuvent être très nombreux. On ne loggue que les previews et les erreurs. if (res.statusCode != 200 || url.contains('size=preview')) { debugPrint('IMMICH_IMAGE_FETCH status=${res.statusCode} bytes=${res.bodyBytes.length} url=$url'); } if (res.statusCode == 200 && res.bodyBytes.isNotEmpty) return res.bodyBytes; throw Exception('Erreur image ${res.statusCode}'); } static Future fetchAssetPreviewBytes(String id) async { try { return await fetchImageBytes(assetPreviewUrl(id)); } catch (_) { return fetchImageBytes(assetThumbUrl(id)); } } static Future fetchAssetThumbnailBytes(String id) async { try { return await fetchImageBytes(assetThumbUrl(id)); } catch (_) { return fetchImageBytes(assetPreviewUrl(id)); } } static Future fetchPersonBytes(String id) => fetchImageBytes(personThumbnailUrl(id)); } class RandomMusicService { static final AudioPlayer _player = AudioPlayer(); static final Random _random = Random(); static List _musicAssets = []; static String? currentTrack; static bool _initialized = false; static bool _enabled = true; static Future initialize() async { if (_initialized) { return; } _initialized = true; try { final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); _musicAssets = assetManifest .listAssets() .where((path) => path.toLowerCase().startsWith('music/')) .where((path) { final lower = path.toLowerCase(); return lower.endsWith('.mp3') || lower.endsWith('.m4a') || lower.endsWith('.aac') || lower.endsWith('.wav') || lower.endsWith('.ogg'); }) .toList(); debugPrint('IMMICH_MUSIC found ${_musicAssets.length} tracks'); _player.playerStateStream.listen((state) { if (state.processingState == ProcessingState.completed && _enabled) { playRandomTrack(); } }); } catch (error) { debugPrint('IMMICH_MUSIC init error: $error'); _musicAssets = []; } } static Future startRandomMusic() async { _enabled = true; await initialize(); if (_musicAssets.isEmpty) { debugPrint('IMMICH_MUSIC no music files found in Music/'); return; } if (_player.playing) { return; } await playRandomTrack(); } static Future playRandomTrack() async { if (!_enabled || _musicAssets.isEmpty) { return; } try { var nextTrack = _musicAssets[_random.nextInt(_musicAssets.length)]; if (_musicAssets.length > 1 && nextTrack == currentTrack) { nextTrack = _musicAssets[_random.nextInt(_musicAssets.length)]; } currentTrack = nextTrack; debugPrint('IMMICH_MUSIC play $nextTrack'); await _player.setAsset(nextTrack); await _player.setLoopMode(LoopMode.off); await _player.play(); } catch (error) { debugPrint('IMMICH_MUSIC play error: $error'); } } static Future stop() async { _enabled = false; try { await _player.stop(); } catch (error) { debugPrint('IMMICH_MUSIC stop error: $error'); } } static Future dispose() async { try { await _player.dispose(); } catch (_) {} } } class HomeShell extends StatefulWidget { const HomeShell({super.key}); @override State createState() => _HomeShellState(); } class _HomeShellState extends State { AppSection selected = AppSection.photos; Future confirmExit() async { final result = await showDialog( context: context, barrierDismissible: true, builder: (dialogContext) { return AlertDialog( backgroundColor: const Color(0xFF111827), title: const Text( 'Quitter Imedia Photos ?', style: TextStyle( color: Colors.white, fontWeight: FontWeight.w700, ), ), content: const Text( 'Tu veux vraiment fermer l’application ?', style: TextStyle( color: Colors.white70, fontSize: 18, ), ), actions: [ TextButton( autofocus: true, onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Quitter'), ), ], ); }, ); return result == true; } @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) { return; } if (SettingsBackHandler.run()) { return; } final shouldExit = await confirmExit(); if (shouldExit) { SystemNavigator.pop(); } }, child: Scaffold( backgroundColor: AppColors.background, body: Row( children: [ Sidebar( selectedSection: selected, onSectionSelected: (s) => setState(() => selected = s), ), Expanded( child: selected == AppSection.photos ? const PhotosHomeScreen() : selected == AppSection.videos ? const VideosHomeScreen() : selected == AppSection.explorer ? const ExplorerScreen() : selected == AppSection.surprise ? const SurpriseMeScreen() : const SettingsScreen(), ), ], ), ), ); } } class Sidebar extends StatelessWidget { final AppSection selectedSection; final ValueChanged onSectionSelected; const Sidebar({ super.key, required this.selectedSection, required this.onSectionSelected, }); @override Widget build(BuildContext context) => Container( width: 178, decoration: const BoxDecoration( color: Color(0xFF111827), border: Border(right: BorderSide(color: Colors.white10)), ), child: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), child: Column( children: [ Row( children: const [ Icon(Icons.auto_awesome, color: Color(0xFFFDE68A), size: 20), SizedBox(width: 6), Expanded( child: Text( 'Imedia', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w900, ), ), ), ], ), const SizedBox(height: 22), SidebarButton( icon: Icons.photo_library_outlined, label: 'Photos', selected: selectedSection == AppSection.photos, onPressed: () => onSectionSelected(AppSection.photos), ), SidebarButton( icon: Icons.video_library_outlined, label: 'Vidéos', selected: selectedSection == AppSection.videos, onPressed: () => onSectionSelected(AppSection.videos), ), SidebarButton( icon: Icons.manage_search, label: 'Explorer', selected: selectedSection == AppSection.explorer, onPressed: () => onSectionSelected(AppSection.explorer), ), SidebarButton( icon: Icons.shuffle, label: 'Surprise', selected: selectedSection == AppSection.surprise, onPressed: () => onSectionSelected(AppSection.surprise), ), SidebarButton( icon: Icons.settings_outlined, label: 'Réglages', selected: selectedSection == AppSection.settings, onPressed: () => onSectionSelected(AppSection.settings), ), const Spacer(), const Text( 'Imedia Photos', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: Colors.white38, fontSize: 11), ), ], ), ), ), ); } class SidebarButton extends StatelessWidget { final IconData icon; final String label; final bool selected; final VoidCallback onPressed; const SidebarButton({ super.key, required this.icon, required this.label, required this.selected, required this.onPressed, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: TvCard( onPressed: onPressed, backgroundColor: selected ? AppColors.selected : Colors.transparent, focusedColor: selected ? AppColors.selected : Colors.white10, borderColor: selected ? AppColors.selectedBorder : Colors.transparent, scaleOnFocus: 1.02, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), child: Row( children: [ Icon(icon, size: 23), const SizedBox(width: 9), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), ), ], ), ), ), ); } } class PhotosHomeScreen extends StatefulWidget { const PhotosHomeScreen({super.key}); @override State createState()=>_PhotosHomeScreenState(); } class _PhotosHomeScreenState extends State { late Future> futurePhotos; @override void initState(){super.initState(); futurePhotos=ImmichApi.getAllTimelinePhotos();} void reload()=>setState(()=>futurePhotos=ImmichApi.getAllTimelinePhotos()); @override Widget build(BuildContext context)=>AppContentScaffold(title:'Photos',subtitle:'Toutes les photos de la bibliothèque Immich',actionLabel:'Rafraîchir',onAction:reload,child:FutureBuilder>(future:futurePhotos,builder:(context,s){ if(s.connectionState==ConnectionState.waiting)return const LoadingView(message:'Chargement de toutes les photos Immich...'); if(s.hasError)return ErrorView(message:s.error.toString()); final photos=s.data??[]; if(photos.isEmpty)return const EmptyView(message:'Aucune photo trouvée'); return PhotoGrid(photos:photos,title:'${photos.length} photos'); })); } class ExplorerScreen extends StatefulWidget { const ExplorerScreen({super.key}); @override State createState()=>_ExplorerScreenState(); } class _ExplorerScreenState extends State { late Future> futurePeople; String progress=''; @override void initState(){super.initState(); futurePeople=loadPeople();} Future> loadPeople()=>ImmichApi.getPeopleWithPhotoCounts(onProgress:(m){if(mounted)setState(()=>progress=m);}); void reload()=>setState(()=>futurePeople=loadPeople()); @override Widget build(BuildContext context)=>AppContentScaffold(title:'Explorer',subtitle:'Personnes détectées par Immich',actionLabel:'Rafraîchir',onAction:reload,child:FutureBuilder>(future:futurePeople,builder:(context,s){ if(s.connectionState==ConnectionState.waiting)return LoadingView(message:progress.isEmpty?'Chargement des personnes...':progress); if(s.hasError)return ErrorView(message:s.error.toString()); final people=s.data??[]; if(people.isEmpty)return const EmptyView(message:'Aucune personne avec photos trouvée'); return PeopleExplorerGrid(people:people); })); } class VideosHomeScreen extends StatefulWidget { const VideosHomeScreen({super.key}); @override State createState() => _VideosHomeScreenState(); } class _VideosHomeScreenState extends State { late Future> futureVideos; @override void initState() { super.initState(); futureVideos = ImmichApi.getAllTimelineVideos(); } void reload() { setState(() { futureVideos = ImmichApi.getAllTimelineVideos(); }); } @override Widget build(BuildContext context) { return AppContentScaffold( title: 'Vidéos', subtitle: 'Toutes les vidéos de la bibliothèque Immich', actionLabel: 'Rafraîchir', onAction: reload, child: FutureBuilder>( future: futureVideos, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const LoadingView(message: 'Chargement de toutes les vidéos Immich...'); } if (snapshot.hasError) { return ErrorView(message: snapshot.error.toString()); } final videos = snapshot.data ?? []; if (videos.isEmpty) { return const EmptyView(message: 'Aucune vidéo trouvée'); } return VideoGrid( videos: videos, title: '${videos.length} vidéos', ); }, ), ); } } class VideoGrid extends StatelessWidget { final List videos; final String title; const VideoGrid({ super.key, required this.videos, required this.title, }); @override Widget build(BuildContext context) { return Column( children: [ Align( alignment: Alignment.centerRight, child: Text( title, style: const TextStyle( fontSize: 20, color: Colors.white70, fontWeight: FontWeight.w600, ), ), ), const SizedBox(height: 20), Expanded( child: GridView.builder( padding: const EdgeInsets.only(bottom: 32), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 5, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1.35, ), itemCount: videos.length, itemBuilder: (context, index) { final asset = videos[index]; return TvCard( onPressed: () { Navigator.of(context).push( MaterialPageRoute( builder: (_) => VideoViewerScreen( videos: videos, initialIndex: index, ), ), ); }, child: Stack( fit: StackFit.expand, children: [ ClipRRect( borderRadius: BorderRadius.circular(16), child: ImmichAssetImage( assetId: asset.id, fit: BoxFit.cover, preview: false, ), ), DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: Colors.black.withOpacity(0.18), ), ), const Center( child: Icon( Icons.play_circle_fill, size: 58, color: Colors.white, ), ), ], ), ); }, ), ), ], ); } } class VideoViewerScreen extends StatefulWidget { final List videos; final int initialIndex; const VideoViewerScreen({ super.key, required this.videos, required this.initialIndex, }); @override State createState() => _VideoViewerScreenState(); } class _VideoViewerScreenState extends State { late int currentIndex; late FocusNode focusNode; VideoPlayerController? controller; Timer? skipTimer; bool isLoading = true; bool isSkippingUnsupported = false; String? errorMessage; int loadToken = 0; ImmichAsset get currentVideo => widget.videos[currentIndex]; @override void initState() { super.initState(); currentIndex = widget.initialIndex; focusNode = FocusNode(); WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); loadCurrentVideo(); }); } @override void dispose() { skipTimer?.cancel(); controller?.removeListener(videoListener); controller?.dispose(); focusNode.dispose(); super.dispose(); } Future loadCurrentVideo() async { final token = ++loadToken; skipTimer?.cancel(); final oldController = controller; oldController?.removeListener(videoListener); controller = null; await oldController?.dispose(); if (!mounted || token != loadToken) { return; } setState(() { isLoading = true; isSkippingUnsupported = false; errorMessage = null; }); final asset = currentVideo; final url = ImmichApi.assetVideoPlaybackUrl(asset.id); debugPrint('IMMICH_VIDEO play index=${currentIndex + 1}/${widget.videos.length} asset=${asset.id} url=$url'); try { final newController = VideoPlayerController.networkUrl( Uri.parse(url), httpHeaders: ImmichApi.headers, ); controller = newController; await newController.initialize(); if (!mounted || token != loadToken) { await newController.dispose(); return; } await newController.setLooping(false); newController.addListener(videoListener); setState(() { isLoading = false; isSkippingUnsupported = false; errorMessage = null; }); await newController.play(); } catch (error) { debugPrint('IMMICH_VIDEO unsupported_or_error asset=${asset.id} error=$error'); if (!mounted || token != loadToken) { return; } handleVideoError(error.toString()); } } void handleVideoError(String message) { final lower = message.toLowerCase(); final looksUnsupported = lower.contains('no_exceeds_capabilities') || lower.contains('exceeds_capabilities') || lower.contains('mediacodec') || lower.contains('format_supported') || lower.contains('videorenderer'); if (looksUnsupported && widget.videos.length > 1) { setState(() { isLoading = false; isSkippingUnsupported = true; errorMessage = 'Vidéo incompatible Mi Box. Passage à la suivante...'; }); skipTimer?.cancel(); skipTimer = Timer(const Duration(seconds: 2), () { if (!mounted) { return; } nextVideo(loop: true); }); return; } setState(() { isLoading = false; isSkippingUnsupported = false; errorMessage = message; }); } void videoListener() { final currentController = controller; if (currentController == null || !currentController.value.isInitialized) { return; } if (currentController.value.hasError) { final description = currentController.value.errorDescription ?? 'Erreur vidéo'; debugPrint('IMMICH_VIDEO runtime_error $description'); handleVideoError(description); return; } final position = currentController.value.position; final duration = currentController.value.duration; if (duration.inMilliseconds > 0 && position >= duration - const Duration(milliseconds: 500)) { nextVideo(loop: true); } } void previousVideo() { if (currentIndex > 0) { setState(() { currentIndex--; }); loadCurrentVideo(); } } void nextVideo({bool loop = false}) { if (currentIndex < widget.videos.length - 1) { setState(() { currentIndex++; }); loadCurrentVideo(); return; } if (loop && widget.videos.isNotEmpty) { setState(() { currentIndex = 0; }); loadCurrentVideo(); } } void togglePlayPause() { final currentController = controller; if (currentController == null || !currentController.value.isInitialized) { return; } if (currentController.value.isPlaying) { currentController.pause(); } else { currentController.play(); } setState(() {}); } void closeViewer() { skipTimer?.cancel(); Navigator.of(context).pop(); } void handleKey(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.arrowLeft) { previousVideo(); return; } if (key == LogicalKeyboardKey.arrowRight) { nextVideo(loop: true); return; } if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter || key == LogicalKeyboardKey.space) { togglePlayPause(); return; } if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.goBack) { closeViewer(); return; } } @override Widget build(BuildContext context) { final currentController = controller; return Scaffold( backgroundColor: Colors.black, body: KeyboardListener( focusNode: focusNode, onKeyEvent: (event) { if (event is! KeyDownEvent) { return; } handleKey(event.logicalKey); }, child: Stack( children: [ Center( child: Builder( builder: (context) { if (isLoading) { return const VideoStatusView( icon: Icons.hourglass_empty, title: 'Chargement de la vidéo...', subtitle: '', ); } if (errorMessage != null) { return VideoStatusView( icon: isSkippingUnsupported ? Icons.skip_next : Icons.error_outline, title: isSkippingUnsupported ? 'Vidéo incompatible Mi Box' : 'Erreur de lecture vidéo', subtitle: isSkippingUnsupported ? 'Passage automatique à la suivante' : errorMessage!, ); } if (currentController == null || !currentController.value.isInitialized) { return const VideoStatusView( icon: Icons.videocam_off_outlined, title: 'Vidéo indisponible', subtitle: '', ); } return AspectRatio( aspectRatio: currentController.value.aspectRatio, child: VideoPlayer(currentController), ); }, ), ), Positioned( left: 34, bottom: 34, child: ClipRRect( borderRadius: BorderRadius.circular(22), child: BackdropFilter( filter: ImageFilter.blur( sigmaX: 14, sigmaY: 14, ), child: Container( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 14, ), decoration: BoxDecoration( color: Colors.white.withOpacity(0.10), borderRadius: BorderRadius.circular(22), border: Border.all( color: Colors.white.withOpacity(0.28), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( isSkippingUnsupported ? Icons.skip_next : currentController?.value.isPlaying == true ? Icons.pause_circle_outline : Icons.play_circle_outline, color: Colors.white, size: 28, ), const SizedBox(width: 12), Text( '${currentIndex + 1} / ${widget.videos.length}', style: const TextStyle( color: Colors.white, fontSize: 19, fontWeight: FontWeight.w700, ), ), const SizedBox(width: 16), const Text( '← / → zapper OK pause', style: TextStyle( color: Colors.white70, fontSize: 15, ), ), ], ), ), ), ), ), ], ), ), ); } } class VideoStatusView extends StatelessWidget { final IconData icon; final String title; final String subtitle; const VideoStatusView({ super.key, required this.icon, required this.title, required this.subtitle, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(42), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: Colors.white70, size: 72, ), const SizedBox(height: 24), Text( title, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 30, fontWeight: FontWeight.w700, ), ), if (subtitle.trim().isNotEmpty) ...[ const SizedBox(height: 12), Text( subtitle, textAlign: TextAlign.center, maxLines: 4, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white70, fontSize: 20, ), ), ], ], ), ); } } class SurpriseMeScreen extends StatefulWidget { const SurpriseMeScreen({super.key}); @override State createState()=>_SurpriseMeScreenState(); } class _SurpriseMeScreenState extends State { late Future> futureRandomPhotos; @override void initState() { super.initState(); futureRandomPhotos = ImmichApi.getRandomTimelinePhotos(); } void reload() { setState(() { futureRandomPhotos = ImmichApi.getRandomTimelinePhotos(); }); } void launchSurprise(List photos, {int initialIndex = 0}) { if (photos.isEmpty) return; Navigator.of(context).push( MaterialPageRoute( builder: (_) => PhotoViewerScreen( photos: photos, initialIndex: initialIndex, initialAutoZapping: true, ), ), ); } @override Widget build(BuildContext context) { return AppContentScaffold( title:'Surprends-moi', subtitle:'Zapping aléatoire des photos toutes les ${AppSettings.autoDelaySeconds} secondes', actionLabel:'Mélanger', onAction:reload, child:FutureBuilder>( future:futureRandomPhotos, builder:(context,snapshot){ if(snapshot.connectionState==ConnectionState.waiting){ return const LoadingView(message:'Préparation du zapping aléatoire avec toutes les photos...'); } if(snapshot.hasError){return ErrorView(message:snapshot.error.toString());} final photos=snapshot.data??[]; if(photos.isEmpty){return const EmptyView(message:'Aucune photo disponible');} final previewCount=photos.length>30?30:photos.length; return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ TvCard( onPressed:()=>launchSurprise(photos), backgroundColor:AppColors.selected, focusedColor:AppColors.selected, borderColor:AppColors.selectedBorder, scaleOnFocus:1.04, child:Container( width:double.infinity, padding:const EdgeInsets.symmetric(horizontal:28,vertical:24), child: Row(children:[ Icon(Icons.shuffle,size:34), SizedBox(width:18), Text('Lancer le zapping aléatoire',style:TextStyle(fontSize:26,fontWeight:FontWeight.w700)), Spacer(), Text('${AppSettings.autoDelaySeconds} secondes par photo',style:const TextStyle(fontSize:20,color:Colors.white70)), ]), ), ), const SizedBox(height:28), Text('${photos.length} photos mélangées',style:const TextStyle(fontSize:20,color:Colors.white70,fontWeight:FontWeight.w600)), const SizedBox(height:20), Expanded(child:GridView.builder( padding:const EdgeInsets.only(bottom:32), gridDelegate:const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount:5,crossAxisSpacing:16,mainAxisSpacing:16,childAspectRatio:1.35), itemCount:previewCount, itemBuilder:(context,index){ final asset=photos[index]; return TvCard( onPressed:()=>launchSurprise(photos,initialIndex:index), child:ClipRRect(borderRadius:BorderRadius.circular(16),child:ImmichAssetImage(assetId:asset.id,fit:BoxFit.cover,preview:false)), ); }, )), ]); }, ), ); } } class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { late final TextEditingController ipController; late final TextEditingController portController; late final TextEditingController apiKeyController; late final TextEditingController delayController; late final FocusNode serverTabFocus; late final FocusNode apiTabFocus; late final FocusNode zappingTabFocus; late final FocusNode ipFocus; late final FocusNode portFocus; late final FocusNode apiFocus; late final FocusNode delayFocus; late final FocusNode saveFocus; late final FocusNode resetFocus; int selectedTab = 0; String statusMessage = ''; @override void initState() { super.initState(); ipController = TextEditingController(text: AppSettings.serverIp); portController = TextEditingController(text: AppSettings.port); apiKeyController = TextEditingController(text: AppSettings.apiKey == AppSettings.defaultApiKey ? '' : AppSettings.apiKey); delayController = TextEditingController(text: AppSettings.autoDelaySeconds.toString()); ipController.addListener(() => setState(() {})); portController.addListener(() => setState(() {})); serverTabFocus = FocusNode(debugLabel: 'settings_tab_server'); apiTabFocus = FocusNode(debugLabel: 'settings_tab_api'); zappingTabFocus = FocusNode(debugLabel: 'settings_tab_zapping'); ipFocus = FocusNode(debugLabel: 'settings_ip'); portFocus = FocusNode(debugLabel: 'settings_port'); apiFocus = FocusNode(debugLabel: 'settings_api_key'); delayFocus = FocusNode(debugLabel: 'settings_delay'); saveFocus = FocusNode(debugLabel: 'settings_save'); resetFocus = FocusNode(debugLabel: 'settings_reset'); ipFocus.onKeyEvent = (node, event) => handleTextFieldKey( event: event, up: serverTabFocus, down: portFocus, ); portFocus.onKeyEvent = (node, event) => handleTextFieldKey( event: event, up: ipFocus, down: saveFocus, ); apiFocus.onKeyEvent = (node, event) => handleTextFieldKey( event: event, up: apiTabFocus, down: saveFocus, ); delayFocus.onKeyEvent = (node, event) => handleTextFieldKey( event: event, up: zappingTabFocus, down: saveFocus, ); SettingsBackHandler.handleBack = handleBackFromSettings; } @override void dispose() { if (SettingsBackHandler.handleBack == handleBackFromSettings) { SettingsBackHandler.handleBack = null; } ipController.dispose(); portController.dispose(); apiKeyController.dispose(); delayController.dispose(); serverTabFocus.dispose(); apiTabFocus.dispose(); zappingTabFocus.dispose(); ipFocus.dispose(); portFocus.dispose(); apiFocus.dispose(); delayFocus.dispose(); saveFocus.dispose(); resetFocus.dispose(); super.dispose(); } bool isSettingsTextFieldFocused() { return ipFocus.hasFocus || portFocus.hasFocus || apiFocus.hasFocus || delayFocus.hasFocus; } bool handleBackFromSettings() { if (!isSettingsTextFieldFocused()) { return false; } hideKeyboard(); if (selectedTab == 0) { serverTabFocus.requestFocus(); } else if (selectedTab == 1) { apiTabFocus.requestFocus(); } else { zappingTabFocus.requestFocus(); } return true; } void hideKeyboard() { SystemChannels.textInput.invokeMethod('TextInput.hide'); } void showKeyboard() { SystemChannels.textInput.invokeMethod('TextInput.show'); } void focusOnly(FocusNode node) { hideKeyboard(); node.requestFocus(); } void selectTab(int index) { setState(() { selectedTab = index; statusMessage = ''; }); hideKeyboard(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) { return; } if (index == 0) { serverTabFocus.requestFocus(); } else if (index == 1) { apiTabFocus.requestFocus(); } else { zappingTabFocus.requestFocus(); } }); } Future saveSettings() async { final delay = int.tryParse(delayController.text.trim()) ?? AppSettings.defaultAutoDelaySeconds; await AppSettings.save( newServerIp: ipController.text, newPort: portController.text, newApiKey: apiKeyController.text, newAutoDelaySeconds: delay, ); ImmichApi.clearCaches(); if (!mounted) { return; } setState(() { ipController.text = AppSettings.serverIp; portController.text = AppSettings.port; apiKeyController.text = AppSettings.apiKey == AppSettings.defaultApiKey ? '' : AppSettings.apiKey; delayController.text = AppSettings.autoDelaySeconds.toString(); statusMessage = 'Paramètres enregistrés'; }); hideKeyboard(); saveFocus.requestFocus(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Paramètres enregistrés : ${AppSettings.immichBaseUrl}'), ), ); } Future resetDefaults() async { ipController.text = AppSettings.defaultServerIp; portController.text = AppSettings.defaultPort; apiKeyController.text = ''; delayController.text = AppSettings.defaultAutoDelaySeconds.toString(); await saveSettings(); } KeyEventResult handleTextFieldKey({ required KeyEvent event, required FocusNode up, required FocusNode down, }) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (key == LogicalKeyboardKey.arrowUp) { focusOnly(up); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown) { focusOnly(down); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.goBack || key == LogicalKeyboardKey.escape) { handleBackFromSettings(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { showKeyboard(); return KeyEventResult.ignored; } return KeyEventResult.ignored; } Widget buildTabContent() { if (selectedTab == 0) { return SettingsCard( title: 'Serveur Immich', children: [ TvSettingsTextField( controller: ipController, focusNode: ipFocus, label: 'Adresse IP', hint: '192.168.1.29', icon: Icons.dns_outlined, keyboardType: TextInputType.number, ), const SizedBox(height: 14), TvSettingsTextField( controller: portController, focusNode: portFocus, label: 'Port', hint: '2283', icon: Icons.settings_ethernet, keyboardType: TextInputType.number, ), const SizedBox(height: 14), Text( 'URL utilisée : http://${ipController.text.trim().isEmpty ? 'IP' : ipController.text.trim()}:${portController.text.trim().isEmpty ? 'PORT' : portController.text.trim()}/api', style: const TextStyle( color: Colors.white54, fontSize: 15, ), ), ], ); } if (selectedTab == 1) { return SettingsCard( title: 'Clé API Immich', children: [ TvSettingsTextField( controller: apiKeyController, focusNode: apiFocus, label: 'Clé API', hint: 'Colle ici la clé API', icon: Icons.key, obscureText: true, ), const SizedBox(height: 14), const Text( 'L’application utilise uniquement la clé API. Elle ne stocke pas ton mot de passe Immich.', style: TextStyle( color: Colors.white54, fontSize: 15, ), ), ], ); } return SettingsCard( title: 'Zapping photo', children: [ TvSettingsTextField( controller: delayController, focusNode: delayFocus, label: 'Délai auto', hint: '10', icon: Icons.timer_outlined, keyboardType: TextInputType.number, ), const SizedBox(height: 14), const Text( 'Valeur autorisée : 3 à 300 secondes.', style: TextStyle( color: Colors.white54, fontSize: 15, ), ), ], ); } @override Widget build(BuildContext context) { return AppContentScaffold( title: 'Réglages', subtitle: 'Navigation TV directe : haut / bas entre les champs', actionLabel: 'Enregistrer', onAction: saveSettings, child: Column( children: [ Row( children: [ SettingsTabButton( focusNode: serverTabFocus, label: 'Serveur', icon: Icons.dns_outlined, selected: selectedTab == 0, onPressed: () => selectTab(0), onDown: () => focusOnly(ipFocus), onRight: () => selectTab(1), ), const SizedBox(width: 10), SettingsTabButton( focusNode: apiTabFocus, label: 'Clé API', icon: Icons.key, selected: selectedTab == 1, onPressed: () => selectTab(1), onDown: () => focusOnly(apiFocus), onLeft: () => selectTab(0), onRight: () => selectTab(2), ), const SizedBox(width: 10), SettingsTabButton( focusNode: zappingTabFocus, label: 'Zapping', icon: Icons.timer_outlined, selected: selectedTab == 2, onPressed: () => selectTab(2), onDown: () => focusOnly(delayFocus), onLeft: () => selectTab(1), ), ], ), const SizedBox(height: 16), Expanded( child: SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 860), child: buildTabContent(), ), ), ), ), const SizedBox(height: 14), Row( children: [ Expanded( child: TvCard( focusNode: saveFocus, onPressed: saveSettings, backgroundColor: AppColors.selected, focusedColor: AppColors.selected, borderColor: AppColors.selectedBorder, scaleOnFocus: 1.03, onUp: () { if (selectedTab == 0) { focusOnly(portFocus); } else if (selectedTab == 1) { focusOnly(apiFocus); } else { focusOnly(delayFocus); } }, onRight: () => resetFocus.requestFocus(), child: const Padding( padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Center( child: Text( 'Enregistrer', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, ), ), ), ), ), ), const SizedBox(width: 14), Expanded( child: TvCard( focusNode: resetFocus, onPressed: resetDefaults, backgroundColor: Colors.white10, focusedColor: Colors.white24, scaleOnFocus: 1.03, onUp: () { if (selectedTab == 0) { focusOnly(portFocus); } else if (selectedTab == 1) { focusOnly(apiFocus); } else { focusOnly(delayFocus); } }, onLeft: () => saveFocus.requestFocus(), child: const Padding( padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Center( child: Text( 'Réinitialiser', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, ), ), ), ), ), ), ], ), if (statusMessage.isNotEmpty) ...[ const SizedBox(height: 8), Text( statusMessage, textAlign: TextAlign.center, style: const TextStyle( color: Colors.lightGreenAccent, fontSize: 14, ), ), ], ], ), ); } } class SettingsTabButton extends StatelessWidget { final FocusNode focusNode; final String label; final IconData icon; final bool selected; final VoidCallback onPressed; final VoidCallback? onUp; final VoidCallback? onDown; final VoidCallback? onLeft; final VoidCallback? onRight; const SettingsTabButton({ super.key, required this.focusNode, required this.label, required this.icon, required this.selected, required this.onPressed, this.onUp, this.onDown, this.onLeft, this.onRight, }); @override Widget build(BuildContext context) { return Expanded( child: TvCard( focusNode: focusNode, onPressed: onPressed, onUp: onUp, onDown: onDown, onLeft: onLeft, onRight: onRight, backgroundColor: selected ? AppColors.selected : Colors.white10, focusedColor: selected ? AppColors.selected : Colors.white24, borderColor: selected ? AppColors.selectedBorder : Colors.transparent, scaleOnFocus: 1.02, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 20), const SizedBox(width: 8), Text( label, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), ], ), ), ), ); } } class TvSettingsTextField extends StatelessWidget { final TextEditingController controller; final FocusNode focusNode; final String label; final String hint; final IconData icon; final bool obscureText; final TextInputType? keyboardType; const TvSettingsTextField({ super.key, required this.controller, required this.focusNode, required this.label, required this.hint, required this.icon, this.obscureText = false, this.keyboardType, }); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: focusNode, builder: (context, _) { final hasFocus = focusNode.hasFocus; return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), border: Border.all( color: hasFocus ? AppColors.selectedBorder : Colors.white24, width: hasFocus ? 3 : 1, ), ), child: TextField( controller: controller, focusNode: focusNode, obscureText: obscureText, keyboardType: keyboardType, textInputAction: TextInputAction.done, onTap: () { focusNode.requestFocus(); SystemChannels.textInput.invokeMethod('TextInput.show'); }, onSubmitted: (_) { SystemChannels.textInput.invokeMethod('TextInput.hide'); }, style: const TextStyle( color: Colors.white, fontSize: 19, ), decoration: InputDecoration( prefixIcon: Icon(icon, size: 24), labelText: label, hintText: hint, filled: true, fillColor: Colors.white10, contentPadding: const EdgeInsets.symmetric( vertical: 16, horizontal: 16, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none, ), ), ), ); }, ); } } class SettingsCard extends StatelessWidget { final String title; final List children; const SettingsCard({ super.key, required this.title, required this.children, }); @override Widget build(BuildContext context) { return Card( color: AppColors.card, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), side: const BorderSide( color: Colors.white12, ), ), child: Padding( padding: const EdgeInsets.all(22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( title, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 18), ...children, ], ), ), ); } } class AppContentScaffold extends StatelessWidget { final String title, subtitle, actionLabel; final VoidCallback onAction; final Widget child; const AppContentScaffold({ super.key, required this.title, required this.subtitle, required this.actionLabel, required this.onAction, required this.child, }); @override Widget build(BuildContext context) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(34, 22, 34, 22), child: Column( children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 34, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 4), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 17, color: AppColors.mutedText, ), ), ], ), ), TvCard( onPressed: onAction, scaleOnFocus: 1.04, backgroundColor: Colors.white10, focusedColor: Colors.white24, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), child: Text( actionLabel, style: const TextStyle(fontSize: 18), ), ), ), ], ), const SizedBox(height: 22), Expanded(child: child), ], ), ), ); } class HeaderText extends StatelessWidget{final String title,subtitle; const HeaderText({super.key,required this.title,required this.subtitle}); @override Widget build(BuildContext context)=>Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Text(title,style:const TextStyle(fontSize:36,fontWeight:FontWeight.w700)),const SizedBox(height:6),Text(subtitle,style:const TextStyle(fontSize:18,color:AppColors.mutedText))]);} class PeopleExplorerGrid extends StatelessWidget { final List people; const PeopleExplorerGrid({super.key,required this.people}); @override Widget build(BuildContext context)=>GridView.builder(padding:const EdgeInsets.only(bottom:32),gridDelegate:const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount:5,crossAxisSpacing:22,mainAxisSpacing:22,childAspectRatio:.78),itemCount:people.length,itemBuilder:(context,index){ final item=people[index]; final person=item.person; return TvCard(debugLabel:'person ${person.displayName(index)} ${person.id}',onPressed:(){debugPrint('IMMICH_DEBUG selected person ${person.displayName(index)} id=${person.id}'); Navigator.of(context).push(MaterialPageRoute(builder:(_)=>PersonPhotosScreen(person:person,personIndex:index)));},child:PersonCard(item:item,index:index)); }); } class PersonCard extends StatelessWidget { final PersonWithCount item; final int index; const PersonCard({super.key,required this.item,required this.index}); @override Widget build(BuildContext context){final p=item.person; return Stack(fit:StackFit.expand,children:[ClipRRect(borderRadius:BorderRadius.circular(18),child:ImmichPersonImage(personId:p.id,fit:BoxFit.cover)),DecoratedBox(decoration:BoxDecoration(borderRadius:BorderRadius.circular(18),gradient:const LinearGradient(begin:Alignment.topCenter,end:Alignment.bottomCenter,colors:[Colors.transparent,Color(0xDD000000)]))),Positioned(left:18,right:18,bottom:18,child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Text(p.displayName(index),maxLines:1,overflow:TextOverflow.ellipsis,style:const TextStyle(fontSize:22,fontWeight:FontWeight.w700)),const SizedBox(height:6),Text('${item.photoCount} photos',style:const TextStyle(fontSize:18,color:Colors.white70)),const SizedBox(height:4),Text(p.id.substring(0,8),style:const TextStyle(fontSize:13,color:Colors.white38))]))]);} } class PersonPhotosScreen extends StatefulWidget{final ImmichPerson person; final int personIndex; const PersonPhotosScreen({super.key,required this.person,required this.personIndex}); @override State createState()=>_PersonPhotosScreenState();} class _PersonPhotosScreenState extends State{late Future> futurePhotos; String get personName=>widget.person.displayName(widget.personIndex); @override void initState(){super.initState();futurePhotos=ImmichApi.getPhotosForPerson(widget.person.id);} void reload()=>setState(()=>futurePhotos=ImmichApi.getPhotosForPerson(widget.person.id)); @override Widget build(BuildContext context)=>Scaffold(backgroundColor:AppColors.background,body:SafeArea(child:Padding(padding:const EdgeInsets.fromLTRB(42,32,42,32),child:Column(children:[Row(children:[TvCard(onPressed:()=>Navigator.of(context).pop(),scaleOnFocus:1.04,backgroundColor:Colors.white10,focusedColor:Colors.white24,child:const Padding(padding:EdgeInsets.all(14),child:Icon(Icons.arrow_back,size:30))),const SizedBox(width:24),Expanded(child:HeaderText(title:'Photos de $personName',subtitle:widget.person.id)),TvCard(onPressed:reload,scaleOnFocus:1.04,backgroundColor:Colors.white10,focusedColor:Colors.white24,child:const Padding(padding:EdgeInsets.symmetric(horizontal:22,vertical:14),child:Text('Rafraîchir',style:TextStyle(fontSize:18))))]),const SizedBox(height:30),Expanded(child:FutureBuilder>(future:futurePhotos,builder:(context,s){if(s.connectionState==ConnectionState.waiting)return LoadingView(message:'Chargement des photos de $personName...'); if(s.hasError)return ErrorView(message:s.error.toString()); final photos=s.data??[]; if(photos.isEmpty)return EmptyView(message:'Aucune photo trouvée pour $personName'); return PhotoGrid(photos:photos,title:'${photos.length} photos');}))]))));} class PhotoGrid extends StatelessWidget { final List photos; final String title; const PhotoGrid({super.key,required this.photos,required this.title}); @override Widget build(BuildContext context)=>Column(children:[Align(alignment:Alignment.centerRight,child:Text(title,style:const TextStyle(fontSize:20,color:Colors.white70,fontWeight:FontWeight.w600))),const SizedBox(height:20),Expanded(child:GridView.builder(padding:const EdgeInsets.only(bottom:32),gridDelegate:const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount:5,crossAxisSpacing:16,mainAxisSpacing:16,childAspectRatio:1.35),itemCount:photos.length,itemBuilder:(context,index){final asset=photos[index]; return TvCard(onPressed:()=>Navigator.of(context).push(MaterialPageRoute(builder:(_)=>PhotoViewerScreen(photos:photos,initialIndex:index))),child:ClipRRect(borderRadius:BorderRadius.circular(16),child:ImmichAssetImage(assetId:asset.id,fit:BoxFit.cover,preview:false)));}))]); } class PhotoViewerScreen extends StatefulWidget { final List photos; final int initialIndex; final bool initialAutoZapping; final bool playMusic; const PhotoViewerScreen({ super.key, required this.photos, required this.initialIndex, this.initialAutoZapping = false, this.playMusic = true, }); @override State createState() => _PhotoViewerScreenState(); } class _PhotoViewerScreenState extends State { late int currentIndex; late FocusNode focusNode; Timer? idleTimer; Timer? zappingTimer; Timer? overlayTimer; bool autoZapping = false; bool showInfoOverlay = true; static const Duration idleBeforeZapping = Duration(minutes: 1); Duration get zappingInterval => Duration(seconds: AppSettings.autoDelaySeconds); static const Duration overlayVisibleDuration = Duration(seconds: 3); static const Duration overlayFadeDuration = Duration(milliseconds: 900); ImmichAsset get currentAsset => widget.photos[currentIndex]; @override void initState() { super.initState(); currentIndex = widget.initialIndex; focusNode = FocusNode(); WidgetsBinding.instance.addPostFrameCallback((_) { focusNode.requestFocus(); showCurrentPhotoOverlay(); if (widget.playMusic) { RandomMusicService.startRandomMusic(); } if (widget.initialAutoZapping) { startAutoZapping(); } else { resetIdleTimer(); } }); } @override void dispose() { idleTimer?.cancel(); zappingTimer?.cancel(); overlayTimer?.cancel(); if (widget.playMusic) { RandomMusicService.stop(); } focusNode.dispose(); super.dispose(); } void showCurrentPhotoOverlay() { overlayTimer?.cancel(); if (mounted) { setState(() { showInfoOverlay = true; }); } overlayTimer = Timer(overlayVisibleDuration, () { if (!mounted) { return; } setState(() { showInfoOverlay = false; }); }); } void resetIdleTimer() { idleTimer?.cancel(); if (widget.photos.length <= 1) { return; } idleTimer = Timer(idleBeforeZapping, startAutoZapping); } void stopAutoZappingAndReset() { zappingTimer?.cancel(); if (autoZapping && mounted) { setState(() { autoZapping = false; }); } resetIdleTimer(); } void startAutoZapping() { if (!mounted || widget.photos.length <= 1) { return; } idleTimer?.cancel(); zappingTimer?.cancel(); setState(() { autoZapping = true; }); zappingTimer = Timer.periodic(zappingInterval, (_) { if (!mounted) { return; } goToNextPhotoLoop(); }); } void setPhotoIndex(int newIndex) { if (newIndex < 0 || newIndex >= widget.photos.length) { return; } setState(() { currentIndex = newIndex; showInfoOverlay = true; }); showCurrentPhotoOverlay(); } void goToNextPhotoLoop() { final nextIndex = currentIndex < widget.photos.length - 1 ? currentIndex + 1 : 0; setPhotoIndex(nextIndex); } void previousPhoto() { stopAutoZappingAndReset(); if (currentIndex > 0) { setPhotoIndex(currentIndex - 1); } } void nextPhoto() { stopAutoZappingAndReset(); if (currentIndex < widget.photos.length - 1) { setPhotoIndex(currentIndex + 1); } } void closeViewer() { idleTimer?.cancel(); zappingTimer?.cancel(); overlayTimer?.cancel(); if (widget.playMusic) { RandomMusicService.stop(); } Navigator.of(context).pop(); } void handleKey(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.arrowLeft) { previousPhoto(); return; } if (key == LogicalKeyboardKey.arrowRight || key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { nextPhoto(); return; } if (key == LogicalKeyboardKey.escape || key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.goBack) { closeViewer(); return; } stopAutoZappingAndReset(); showCurrentPhotoOverlay(); } @override Widget build(BuildContext context) { final asset = currentAsset; return Scaffold( backgroundColor: Colors.black, body: KeyboardListener( focusNode: focusNode, onKeyEvent: (event) { if (event is! KeyDownEvent) { return; } handleKey(event.logicalKey); }, child: Stack( children: [ Center( child: AnimatedSwitcher( duration: const Duration(milliseconds: 450), child: ImmichAssetImage( key: ValueKey(asset.id), assetId: asset.id, fit: BoxFit.contain, preview: true, ), ), ), Positioned( left: 34, bottom: 34, child: PhotoInfoOverlay( asset: asset, visible: showInfoOverlay, fadeDuration: overlayFadeDuration, ), ), ], ), ), ); } } class PhotoInfoOverlay extends StatelessWidget { final ImmichAsset asset; final bool visible; final Duration fadeDuration; const PhotoInfoOverlay({ super.key, required this.asset, required this.visible, required this.fadeDuration, }); @override Widget build(BuildContext context) { return FutureBuilder( future: ImmichApi.getAssetDetails(asset.id), builder: (context, snapshot) { final detailedAsset = snapshot.data ?? asset; return IgnorePointer( child: AnimatedOpacity( opacity: visible ? 1.0 : 0.0, duration: fadeDuration, curve: Curves.easeOut, child: ClipRRect( borderRadius: BorderRadius.circular(22), child: BackdropFilter( filter: ImageFilter.blur( sigmaX: 14, sigmaY: 14, ), child: Container( constraints: const BoxConstraints( minWidth: 240, maxWidth: 560, ), padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 14, ), decoration: BoxDecoration( color: Colors.white.withOpacity(0.10), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Colors.white.withOpacity(0.24), Colors.white.withOpacity(0.08), Colors.black.withOpacity(0.18), ], ), borderRadius: BorderRadius.circular(22), border: Border.all( color: Colors.white.withOpacity(0.28), width: 1, ), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.24), blurRadius: 26, offset: const Offset(0, 10), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.location_on_outlined, color: Colors.white.withOpacity(0.86), size: 24, ), const SizedBox(width: 12), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( detailedAsset.locationLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 19, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 3), Text( detailedAsset.dateLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white.withOpacity(0.72), fontSize: 15, fontWeight: FontWeight.w500, ), ), ], ), ), ], ), ), ), ), ), ); }, ); } } class ImmichPersonImage extends StatelessWidget{final String personId; final BoxFit fit; const ImmichPersonImage({super.key,required this.personId,required this.fit}); @override Widget build(BuildContext context)=>FutureBuilder(future:ImmichApi.fetchPersonBytes(personId),builder:(context,s){if(s.connectionState==ConnectionState.waiting)return const ImageLoadingBox(); if(s.hasError||!s.hasData||s.data!.isEmpty)return const Center(child:Icon(Icons.person,size:80)); return Image.memory(s.data!,fit:fit,width:double.infinity,height:double.infinity,gaplessPlayback:true);});} class ImmichAssetImage extends StatelessWidget{ final String assetId; final BoxFit fit; final bool preview; const ImmichAssetImage({ super.key, required this.assetId, required this.fit, this.preview = true, }); @override Widget build(BuildContext context)=>FutureBuilder( future: preview ? ImmichApi.fetchAssetPreviewBytes(assetId) : ImmichApi.fetchAssetThumbnailBytes(assetId), builder:(context,s){ if(s.connectionState==ConnectionState.waiting)return const ImageLoadingBox(); if(s.hasError||!s.hasData||s.data!.isEmpty)return ImageErrorBox(assetId:assetId); return Image.memory( s.data!, fit:fit, width:double.infinity, height:double.infinity, gaplessPlayback:true, filterQuality: preview ? FilterQuality.medium : FilterQuality.low, ); }, ); } class ImageLoadingBox extends StatelessWidget{const ImageLoadingBox({super.key}); @override Widget build(BuildContext context)=>Container(color:Colors.white10,child:const Center(child:CircularProgressIndicator()));} class ImageErrorBox extends StatelessWidget{final String assetId; const ImageErrorBox({super.key,required this.assetId}); @override Widget build(BuildContext context){final short=assetId.length>=8?assetId.substring(0,8):assetId; return Container(color:Colors.white10,child:Center(child:Column(mainAxisSize:MainAxisSize.min,children:[const Icon(Icons.broken_image,size:56,color:Colors.white54),const SizedBox(height:10),Text('Image indisponible\n$short',textAlign:TextAlign.center,style:const TextStyle(color:Colors.white54,fontSize:14))])));}} class TvCard extends StatefulWidget { final Widget child; final VoidCallback? onPressed; final FocusNode? focusNode; final String? debugLabel; final Color backgroundColor; final Color focusedColor; final Color borderColor; final double scaleOnFocus; final VoidCallback? onUp; final VoidCallback? onDown; final VoidCallback? onLeft; final VoidCallback? onRight; const TvCard({ super.key, required this.child, required this.onPressed, this.focusNode, this.debugLabel, this.backgroundColor = AppColors.card, this.focusedColor = Colors.white24, this.borderColor = Colors.transparent, this.scaleOnFocus = 1.06, this.onUp, this.onDown, this.onLeft, this.onRight, }); @override State createState() => _TvCardState(); } class _TvCardState extends State { bool focused = false; bool isActivateKey(LogicalKeyboardKey key) { final label = key.keyLabel.toLowerCase(); return key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter || key == LogicalKeyboardKey.space || key == LogicalKeyboardKey.gameButtonA || label == 'select' || label == 'enter' || label == 'ok' || label == 'center'; } void activate() { if (widget.debugLabel != null) { debugPrint('IMMICH_DEBUG activate ${widget.debugLabel}'); } widget.onPressed?.call(); } KeyEventResult handleKey(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } final key = event.logicalKey; if (isActivateKey(key)) { activate(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowUp && widget.onUp != null) { widget.onUp!.call(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowDown && widget.onDown != null) { widget.onDown!.call(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowLeft && widget.onLeft != null) { widget.onLeft!.call(); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowRight && widget.onRight != null) { widget.onRight!.call(); return KeyEventResult.handled; } return KeyEventResult.ignored; } @override Widget build(BuildContext context) => Focus( focusNode: widget.focusNode, canRequestFocus: true, descendantsAreFocusable: false, onFocusChange: (value) { setState(() => focused = value); if (value && widget.debugLabel != null) { debugPrint('IMMICH_DEBUG focus ${widget.debugLabel}'); } }, onKeyEvent: handleKey, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: activate, child: AnimatedScale( duration: const Duration(milliseconds: 120), scale: focused ? widget.scaleOnFocus : 1.0, child: Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, color: focused ? widget.focusedColor : widget.backgroundColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), side: BorderSide( color: focused ? AppColors.selectedBorder : widget.borderColor, width: focused ? 3 : 1, ), ), child: widget.child, ), ), ), ); } class LoadingView extends StatelessWidget{final String message; const LoadingView({super.key,required this.message}); @override Widget build(BuildContext context)=>Center(child:Padding(padding:const EdgeInsets.all(32),child:Column(mainAxisSize:MainAxisSize.min,children:[const CircularProgressIndicator(),const SizedBox(height:24),Text(message,textAlign:TextAlign.center,style:const TextStyle(fontSize:24))])));} class EmptyView extends StatelessWidget{final String message; const EmptyView({super.key,required this.message}); @override Widget build(BuildContext context)=>Center(child:Text(message,textAlign:TextAlign.center,style:const TextStyle(color:Colors.white70,fontSize:26)));} class ErrorView extends StatelessWidget{final String message; const ErrorView({super.key,required this.message}); @override Widget build(BuildContext context)=>Center(child:Padding(padding:const EdgeInsets.all(32),child:Text(message,textAlign:TextAlign.center,style:const TextStyle(color:Colors.redAccent,fontSize:22))));}