You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

307 lines
9.1 KiB

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../core/service_locator.dart';
/// Media gallery screen for viewing recipe images and videos in full screen.
/// Supports swiping between media and pinch-to-zoom for images.
class PhotoGalleryScreen extends StatefulWidget {
final List<String> imageUrls;
final List<String> videoUrls;
final int initialIndex;
const PhotoGalleryScreen({
super.key,
required this.imageUrls,
this.videoUrls = const [],
this.initialIndex = 0,
});
@override
State<PhotoGalleryScreen> createState() => _PhotoGalleryScreenState();
}
class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
late PageController _pageController;
late int _currentIndex;
final Map<int, VideoPlayerController> _videoControllers = {};
List<String> get _allMediaUrls {
return [...widget.imageUrls, ...widget.videoUrls];
}
bool _isVideo(int index) {
return index >= widget.imageUrls.length;
}
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
_initializeVideo(_currentIndex);
}
@override
void dispose() {
_pageController.dispose();
for (final controller in _videoControllers.values) {
controller.dispose();
}
super.dispose();
}
void _initializeVideo(int index) {
if (_isVideo(index)) {
final videoIndex = index - widget.imageUrls.length;
if (videoIndex >= 0 && videoIndex < widget.videoUrls.length) {
final videoUrl = widget.videoUrls[videoIndex];
if (!_videoControllers.containsKey(index)) {
final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl));
controller.initialize().then((_) {
if (mounted) {
setState(() {});
controller.play();
}
});
_videoControllers[index] = controller;
}
}
}
}
void _disposeVideo(int index) {
final controller = _videoControllers.remove(index);
controller?.dispose();
}
void _goToPrevious() {
if (_currentIndex > 0) {
_disposeVideo(_currentIndex);
_currentIndex--;
_initializeVideo(_currentIndex);
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _goToNext() {
if (_currentIndex < _allMediaUrls.length - 1) {
_disposeVideo(_currentIndex);
_currentIndex++;
_initializeVideo(_currentIndex);
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
),
),
title: Text(
'${_currentIndex + 1} / ${_allMediaUrls.length}',
style: const TextStyle(color: Colors.white),
),
centerTitle: true,
),
body: Stack(
children: [
// Media viewer
PageView.builder(
controller: _pageController,
itemCount: _allMediaUrls.length,
onPageChanged: (index) {
setState(() {
_disposeVideo(_currentIndex);
_currentIndex = index;
_initializeVideo(_currentIndex);
});
},
itemBuilder: (context, index) {
if (_isVideo(index)) {
return _buildVideo(index);
} else {
return Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 3.0,
child: _buildImage(widget.imageUrls[index]),
),
);
}
},
),
// Navigation arrows
if (_allMediaUrls.length > 1) ...[
// Previous arrow (left)
if (_currentIndex > 0)
Positioned(
left: 16,
top: 0,
bottom: 0,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(
Icons.chevron_left,
color: Colors.white,
size: 32,
),
onPressed: _goToPrevious,
padding: const EdgeInsets.all(16),
),
),
),
),
// Next arrow (right)
if (_currentIndex < _allMediaUrls.length - 1)
Positioned(
right: 16,
top: 0,
bottom: 0,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(
Icons.chevron_right,
color: Colors.white,
size: 32,
),
onPressed: _goToNext,
padding: const EdgeInsets.all(16),
),
),
),
),
],
],
),
);
}
/// Builds an image widget using MediaService for authenticated access.
Widget _buildImage(String imageUrl) {
final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) {
return Image.network(imageUrl, fit: BoxFit.contain);
}
// Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original)
final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl);
String? assetId;
if (assetIdMatch != null) {
assetId = assetIdMatch.group(1);
} else {
// For Blossom URLs, use the full URL
assetId = imageUrl;
}
if (assetId != null) {
// Use MediaService to fetch full image (not thumbnail) for gallery view
return FutureBuilder<Uint8List?>(
future: mediaService.fetchImageBytes(assetId, isThumbnail: false),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
);
}
if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) {
return const Center(
child: Icon(
Icons.image_not_supported,
size: 64,
color: Colors.white54,
),
);
}
return Image.memory(
snapshot.data!,
fit: BoxFit.contain,
);
},
);
}
// Fallback to direct network image if not an Immich URL or service unavailable
return Image.network(
imageUrl,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
Icons.image_not_supported,
size: 64,
color: Colors.white54,
),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
),
);
},
);
}
/// Builds a video player widget.
Widget _buildVideo(int index) {
final controller = _videoControllers[index];
if (controller == null || !controller.value.isInitialized) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
);
}
return Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
);
}
}

Powered by TurnKey Linux.