Files

2807 lines
87 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> 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<void> 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<void> 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<String, dynamic> 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<String, dynamic> json) {
final exifInfo = json['exifInfo'] is Map<String, dynamic>
? json['exifInfo'] as Map<String, dynamic>
: <String, dynamic>{};
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 = <String>[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<ImmichAsset> assets;
final String? nextPage;
SearchPageResult({required this.assets, required this.nextPage});
}
class ImmichApi {
static Map<String, String> get headers => {'x-api-key': AppSettings.apiKey};
static final Map<String, Future<Uint8List>> _imageBytesCache = {};
static final Map<String, Future<ImmichAsset>> _assetDetailsCache = {};
static void clearCaches() {
_imageBytesCache.clear();
_assetDetailsCache.clear();
}
static Future<List<ImmichPerson>> getAllPeople() async {
final all = <ImmichPerson>[];
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<String, dynamic>;
final people = (data['people'] as List<dynamic>? ?? [])
.map((e) => ImmichPerson.fromJson(e as Map<String, dynamic>))
.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<List<PersonWithCount>> getPeopleWithPhotoCounts({void Function(String)? onProgress}) async {
final people = await getAllPeople();
final result = <PersonWithCount>[];
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<int> 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<List<ImmichAsset>> getAllTimelinePhotos({int maxPages = 9999}) async {
final all=<ImmichAsset>[];
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<List<ImmichAsset>> getRandomTimelinePhotos({int maxPages = 9999}) async {
final photos = await getAllTimelinePhotos(maxPages: maxPages);
photos.shuffle(Random());
return photos;
}
static Future<List<ImmichAsset>> getAllTimelineVideos({
int maxPages = 9999,
}) async {
final allAssets = <ImmichAsset>[];
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<SearchPageResult> 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<List<ImmichAsset>> getPhotosForPerson(String personId) async {
final all=<ImmichAsset>[];
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<ImmichAsset> getAssetDetails(String assetId) {
if (_assetDetailsCache.containsKey(assetId)) {
return _assetDetailsCache[assetId]!;
}
final future = _getAssetDetailsNoCache(assetId);
_assetDetailsCache[assetId] = future;
return future;
}
static Future<ImmichAsset> _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<String, dynamic>,
);
debugPrint(
'IMMICH_LOCATION asset=$assetId location="${asset.locationLabel}" date="${asset.dateLabel}"',
);
return asset;
}
static Future<SearchPageResult> 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<SearchPageResult> 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<Map<String,dynamic>> _postSearchMetadata(Map<String,dynamic> 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<String,dynamic>;
}
static SearchPageResult _parseSearchPage(Map<String, dynamic> data) {
return _parseSearchPageForType(data, 'IMAGE');
}
static SearchPageResult _parseSearchPageForType(
Map<String, dynamic> data,
String mediaType,
) {
final items = _extractAssetItems(data);
final assets = items
.map((item) => ImmichAsset.fromJson(item as Map<String, dynamic>))
.where((asset) => asset.id.isNotEmpty)
.where((asset) => asset.mediaType == mediaType)
.where((asset) => !asset.isTrashed)
.toList();
return SearchPageResult(
assets: assets,
nextPage: _extractNextPage(data),
);
}
static List<dynamic> _extractAssetItems(Map<String,dynamic> data) {
if (data['assets'] is Map<String,dynamic>) {
final a = data['assets'] as Map<String,dynamic>;
if (a['items'] is List<dynamic>) return a['items'] as List<dynamic>;
}
if (data['items'] is List<dynamic>) return data['items'] as List<dynamic>;
return [];
}
static String? _extractNextPage(Map<String,dynamic> data) {
final v = data['assets'] is Map<String,dynamic> ? (data['assets'] as Map<String,dynamic>)['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<Uint8List> 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<Uint8List> _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<Uint8List> fetchAssetPreviewBytes(String id) async {
try { return await fetchImageBytes(assetPreviewUrl(id)); } catch (_) { return fetchImageBytes(assetThumbUrl(id)); }
}
static Future<Uint8List> fetchAssetThumbnailBytes(String id) async {
try { return await fetchImageBytes(assetThumbUrl(id)); } catch (_) { return fetchImageBytes(assetPreviewUrl(id)); }
}
static Future<Uint8List> fetchPersonBytes(String id) => fetchImageBytes(personThumbnailUrl(id));
}
class RandomMusicService {
static final AudioPlayer _player = AudioPlayer();
static final Random _random = Random();
static List<String> _musicAssets = [];
static String? currentTrack;
static bool _initialized = false;
static bool _enabled = true;
static Future<void> 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<void> 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<void> 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<void> stop() async {
_enabled = false;
try {
await _player.stop();
} catch (error) {
debugPrint('IMMICH_MUSIC stop error: $error');
}
}
static Future<void> dispose() async {
try {
await _player.dispose();
} catch (_) {}
}
}
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
AppSection selected = AppSection.photos;
Future<bool> confirmExit() async {
final result = await showDialog<bool>(
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<AppSection> 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<PhotosHomeScreen> createState()=>_PhotosHomeScreenState(); }
class _PhotosHomeScreenState extends State<PhotosHomeScreen> {
late Future<List<ImmichAsset>> 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<List<ImmichAsset>>(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<ExplorerScreen> createState()=>_ExplorerScreenState(); }
class _ExplorerScreenState extends State<ExplorerScreen> {
late Future<List<PersonWithCount>> futurePeople; String progress='';
@override void initState(){super.initState(); futurePeople=loadPeople();}
Future<List<PersonWithCount>> 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<List<PersonWithCount>>(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<VideosHomeScreen> createState() => _VideosHomeScreenState();
}
class _VideosHomeScreenState extends State<VideosHomeScreen> {
late Future<List<ImmichAsset>> 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<List<ImmichAsset>>(
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<ImmichAsset> 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<ImmichAsset> videos;
final int initialIndex;
const VideoViewerScreen({
super.key,
required this.videos,
required this.initialIndex,
});
@override
State<VideoViewerScreen> createState() => _VideoViewerScreenState();
}
class _VideoViewerScreenState extends State<VideoViewerScreen> {
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<void> 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<SurpriseMeScreen> createState()=>_SurpriseMeScreenState();
}
class _SurpriseMeScreenState extends State<SurpriseMeScreen> {
late Future<List<ImmichAsset>> futureRandomPhotos;
@override
void initState() {
super.initState();
futureRandomPhotos = ImmichApi.getRandomTimelinePhotos();
}
void reload() {
setState(() {
futureRandomPhotos = ImmichApi.getRandomTimelinePhotos();
});
}
void launchSurprise(List<ImmichAsset> 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<List<ImmichAsset>>(
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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
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<void>('TextInput.hide');
}
void showKeyboard() {
SystemChannels.textInput.invokeMethod<void>('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<void> 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<void> 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<void>('TextInput.show');
},
onSubmitted: (_) {
SystemChannels.textInput.invokeMethod<void>('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<Widget> 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<PersonWithCount> 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<PersonPhotosScreen> createState()=>_PersonPhotosScreenState();}
class _PersonPhotosScreenState extends State<PersonPhotosScreen>{late Future<List<ImmichAsset>> 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<List<ImmichAsset>>(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<ImmichAsset> 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<ImmichAsset> 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<PhotoViewerScreen> createState() => _PhotoViewerScreenState();
}
class _PhotoViewerScreenState extends State<PhotoViewerScreen> {
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<ImmichAsset>(
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<Uint8List>(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<Uint8List>(
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<TvCard> createState() => _TvCardState();
}
class _TvCardState extends State<TvCard> {
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))));}