2807 lines
87 KiB
Dart
2807 lines
87 KiB
Dart
|
||
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))));}
|