2 Commits 76d9889789 ... 77badf84f9

Author SHA1 Message Date
  Veloe 77badf84f9 added video caching 3 months ago
  Veloe 621f848f0e added media carousel view with video player 3 months ago

+ 43 - 23
lib/main.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:flutter_cache_manager/flutter_cache_manager.dart';
+import 'package:media_kit/media_kit.dart';
 import 'package:veloe_kemono_party_flutter/app_layout.dart';
 import 'package:veloe_kemono_party_flutter/kemono_client.dart';
 import 'package:veloe_kemono_party_flutter/models/creator.dart';
@@ -48,30 +49,49 @@ final imageCacheManager = CacheManager(
     fileService: HttpFileService(),
   ),
 );
+final videoCacheManager = CacheManager(
+  Config(
+    'kemono_videos',
+    stalePeriod: const Duration(days: 30),
+    maxNrOfCacheObjects: 1024,
+    fileService: HttpFileService(),
+  ),
+);
+final iconsCacheManager = CacheManager(
+  Config(
+    'kemono_icons',
+    stalePeriod: const Duration(days: 30),
+    maxNrOfCacheObjects: 1024,
+    fileService: HttpFileService(),
+  ),
+);
 
 // Main Application
-void main() => runApp(
-  const ProviderScope(
-    child: MaterialApp(
-      home: AppLayout(
-        navItems: [
-          NavItem(
-            label: 'Home',
-            icon: Icons.home,
-            page: HomeScreen(),
-          ),
-          NavItem(
-            label: 'Global Feed (Rukis)',
-            icon: Icons.public,
-            page: PostsScreen( creatorId:'82522', service: 'patreon', withAppBar: false,),
-          ),
-          NavItem(
-            label: 'Settings',
-            icon: Icons.settings,
-            page: Placeholder(), // Replace with actual SettingsScreen
-          ),
-        ],
+void main() { 
+  MediaKit.ensureInitialized();
+  runApp(
+    const ProviderScope(
+      child: MaterialApp(
+        home: AppLayout(
+          navItems: [
+            NavItem(
+              label: 'Home',
+              icon: Icons.home,
+              page: HomeScreen(),
+            ),
+            NavItem(
+              label: 'Global Feed (Rukis)',
+              icon: Icons.public,
+              page: PostsScreen( creatorId:'82522', service: 'patreon', withAppBar: false,),
+            ),
+            NavItem(
+              label: 'Settings',
+              icon: Icons.settings,
+              page: Placeholder(), // Replace with actual SettingsScreen
+            ),
+          ],
+        ),
       ),
     ),
-  ),
-);
+  );
+}

+ 16 - 3
lib/models/attachment.dart

@@ -1,11 +1,24 @@
 class Attachment {
   final String name;
   final String path;
-  
-  Attachment({required this.name, required this.path});
+  final int? size;
+  final String? mimeType; // Add mimeType if available
+
+  Attachment({required this.name, required this.path, this.size, this.mimeType});
+
+  bool get isImage {
+    final ext = link.split('.').last.toLowerCase();
+    return ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext) ||
+        (mimeType?.startsWith('image/') ?? false);
+  }
+
+  bool get isVideo {
+    final ext = link.split('.').last.toLowerCase();
+    return ['mp4', 'mov', 'webm', 'avi'].contains(ext) ||
+        (mimeType?.startsWith('video/') ?? false);
+  }
 
   String get link => 'https://kemono.su/data$path';
-  bool get isImage => path.endsWith('.jpg') || path.endsWith('.png');
 
   factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
     name: json['name'],

+ 3 - 0
lib/models/post.dart

@@ -23,6 +23,9 @@ class Post {
     required this.user,
   });
 
+  List<Attachment> get mediaAttachments => 
+      attachments.where((a) => a.isImage || a.isVideo).toList();
+
   factory Post.fromJson(Map<String, dynamic> json) => Post(
     added: json['added'],
     attachments: List<Attachment>.from(

+ 123 - 0
lib/pages/media_carousel_screen

@@ -0,0 +1,123 @@
+import 'package:flutter/material.dart';
+import 'package:photo_view/photo_view.dart';
+import 'package:veloe_kemono_party_flutter/pages/widgets/video_player_widget';
+import '../models/post.dart';
+
+class MediaCarouselItem {
+  final String url;
+  final String type; // 'image' or 'video'
+
+  final int postIndex;
+  final int attachmentIndex;
+
+  MediaCarouselItem({
+    required this.url,
+    required this.type,
+    required this.postIndex,
+    required this.attachmentIndex,
+  });
+}
+
+class MediaCarouselScreen extends StatefulWidget {
+  final int initialIndex;
+  final List<MediaCarouselItem> mediaItems;
+  final Future<void> Function() loadMorePosts;
+  final List<Post> allPosts;
+
+  const MediaCarouselScreen({
+    super.key,
+    required this.initialIndex,
+    required this.mediaItems,
+    required this.loadMorePosts,
+    required this.allPosts,
+  });
+
+  @override
+  State<MediaCarouselScreen> createState() => _MediaCarouselScreenState();
+}
+
+class _MediaCarouselScreenState extends State<MediaCarouselScreen> {
+  late PageController _pageController;
+  late int _currentIndex;
+  late List<MediaCarouselItem> _mediaItems;
+
+  @override
+  void initState() {
+    super.initState();
+    _currentIndex = widget.initialIndex;
+    _mediaItems = widget.mediaItems;
+    _pageController = PageController(initialPage: widget.initialIndex);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      backgroundColor: Colors.black,
+      body: Stack(
+        children: [
+          PageView.builder(
+            controller: _pageController,
+            itemCount: _mediaItems.length,
+            onPageChanged: (index) {
+              setState(() {
+                _currentIndex = index;
+              });
+              // Check if we are near the end and load more
+              if (index >= _mediaItems.length - 3) {
+                _loadMoreIfNeeded();
+              }
+            },
+            itemBuilder: (context, index) {
+              final item = _mediaItems[index];
+              if (item.type == 'image') {
+                return PhotoView(
+                  imageProvider: NetworkImage(item.url),
+                  backgroundDecoration: const BoxDecoration(color: Colors.black),
+                );
+              } else {
+                return VideoPlayerWidget(videoUrl: item.url);
+              }
+            },
+          ),
+          Positioned(
+            top: 40,
+            left: 20,
+            child: IconButton(
+              icon: const Icon(Icons.close, color: Colors.white),
+              onPressed: () => Navigator.pop(context),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Future<void> _loadMoreIfNeeded() async {
+    if (_currentIndex >= _mediaItems.length - 3) {
+      await widget.loadMorePosts();
+      setState(() {
+        _mediaItems = _compileMediaItems(widget.allPosts);
+      });
+    }
+  }
+
+  List<MediaCarouselItem> _compileMediaItems(List<Post> posts) {
+    List<MediaCarouselItem> items = [];
+    for (int i = 0; i < posts.length; i++) {
+      final post = posts[i];
+      for (int j = 0; j < post.attachments.length; j++) {
+        final attachment = post.attachments[j];
+        items.add(
+          MediaCarouselItem(
+            url: attachment.link,
+            type: attachment.isImage ? "image" : "video",
+            postIndex: i,
+            attachmentIndex: j,
+          ),
+        );
+      }
+    }
+    return items;
+  }
+}
+

+ 4 - 1
lib/pages/posts_screen.dart

@@ -106,6 +106,9 @@ class _PostsScreenState extends ConsumerState<PostsScreen> with PaginationMixin
                   (context, index) => PostContainer(
                     key: ValueKey(allPosts[index].id),
                     post: allPosts[index],
+                    postIndex: index,
+                    allPosts: allPosts,
+                    loadMorePosts: () async { _loadNextPage(); },
                   ),
                   childCount: allPosts.length,
                 ),
@@ -181,7 +184,7 @@ mixin PaginationMixin<T extends ConsumerStatefulWidget> on ConsumerState<T> {
     _isLoading = true;
     
     final notifier = ref.read(postsProvider((
-      postsWidget.creatorId, // Use converted widget reference
+      postsWidget.creatorId,
       postsWidget.service,
       ref.read(searchQueryProvider)
     )).notifier);

+ 1 - 1
lib/pages/widgets/creator_card.dart

@@ -15,7 +15,7 @@ class ResizedImage extends StatelessWidget {
   Widget build(BuildContext context) {
     final screenSize = MediaQuery.of(context).size;
     return CachedNetworkImage(
-      cacheManager: imageCacheManager,
+      cacheManager: iconsCacheManager,
       imageUrl: imageUrl,
       imageBuilder: (context, imageProvider) => Image(
         image: ResizeImage(

+ 144 - 113
lib/pages/widgets/post_container.dart

@@ -2,14 +2,25 @@ import 'package:flutter/material.dart';
 import 'package:flutter_widget_from_html/flutter_widget_from_html.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:veloe_kemono_party_flutter/models/attachment.dart';
+import 'package:veloe_kemono_party_flutter/pages/media_carousel_screen';
+import 'package:veloe_kemono_party_flutter/pages/widgets/video_preview.dart';
 import 'smart_image_container.dart';
 import 'package:share_plus/share_plus.dart';
 import '../../models/post.dart';
 
 class PostContainer extends StatelessWidget {
   final Post post;
+  final int postIndex; // The index of this post in the list of posts (in PostsScreen)
+  final List<Post> allPosts;
+  final Future<void> Function() loadMorePosts;
 
-  const PostContainer({super.key, required this.post});
+  const PostContainer({
+    super.key,
+    required this.post,
+    required this.postIndex,
+    required this.allPosts,
+    required this.loadMorePosts,
+  });
 
   @override
   Widget build(BuildContext context) {
@@ -22,13 +33,143 @@ class PostContainer extends StatelessWidget {
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             _PostHeader(post: post),
-            _PostContentSection(post: post),
+            _buildContent(context, post.attachments),
+            //_PostContentSection(post: post),
             _PostActionsFooter(post: post),
           ],
         ),
       ),
     );
   }
+
+  Widget _buildContent(BuildContext context, List<Attachment> attachments) {
+    final imageAttachments = post.attachments
+      .where((a) => a.isImage || a.isVideo)
+      .toList();
+
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        // Text Content
+        HtmlWidget(
+          post.content,
+          textStyle: Theme.of(context).textTheme.bodyMedium,
+          onTapUrl: (url) => _handleUrlLaunch(context, url),
+        ),
+
+        // Attachments Grid
+        if (imageAttachments.isNotEmpty)
+          LayoutBuilder(
+            builder: (context, constraints) {
+              return Column(
+                children: [
+                  const SizedBox(height: 12),
+                  GridView.builder(
+                    shrinkWrap: true,
+                    physics: const NeverScrollableScrollPhysics(),
+                    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+                      crossAxisCount: _calculateColumnCount(constraints.maxWidth),
+                    ),
+                    itemCount: imageAttachments.length,
+                    itemBuilder: (context, index) => Padding(
+                      padding: const EdgeInsets.all(4),
+                      child: 
+                        _buildMediaPreview(context, imageAttachments[index], index, attachments)
+                      /*SmartImageContainer(
+                        imageUrl: imageAttachments[index].link,
+                        //onTap: () => _handleAttachmentTap(index),
+                      ),*/
+                    ),
+                  ),
+                  // Attachment links list
+                  ...post.attachments.map(
+                    (attachment) => _buildAttachmentLink(context,attachment),
+                  ),
+                ],
+              );
+            },
+          ),
+      ],
+    );
+  }
+
+  Widget _buildAttachmentLink(BuildContext context,Attachment attachment) {
+    return Padding(
+      padding: const EdgeInsets.only(top: 8),
+      child: InkWell(
+        onTap: () => _handleUrlLaunch(context, attachment.link),
+        child: Row(
+          children: [
+            const Icon(Icons.link, size: 16),
+            const SizedBox(width: 8),
+            Expanded(
+              child: Text(
+                attachment.name,
+                style: const TextStyle(
+                  color: Colors.blue,
+                  decoration: TextDecoration.underline,
+                ),
+                overflow: TextOverflow.ellipsis,
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  int _calculateColumnCount(double availableWidth) {
+    return (availableWidth / 400).floor().clamp(1, 3);
+  }
+
+  Widget _buildMediaPreview(BuildContext context,Attachment attachment, int index, List<Attachment> attachments) {
+    return GestureDetector(
+      onTap: () {
+        _openMediaCarousel(context, index);
+      },
+      child: attachment.isVideo
+          ? VideoPreview(videoUrl: attachment.link, onTap: () => _openMediaCarousel(context, index),)
+          : (attachment.isImage ? SmartImageContainer(imageUrl: attachment.link) : null),
+    );
+  }
+
+  void _openMediaCarousel(BuildContext context, int attachmentIndex) {
+    List<MediaCarouselItem> mediaItems = [];
+    for (int i = 0; i < allPosts.length; i++) {
+      final post = allPosts[i];
+      for (int j = 0; j < post.attachments.length; j++) {
+        final attachment = post.attachments[j];
+        mediaItems.add(
+          MediaCarouselItem(
+            url: attachment.link,
+            type: attachment.isVideo ? "video" : "image",
+            postIndex: i,
+            attachmentIndex: j,
+          ),
+        );
+      }
+    }
+
+    int globalIndex = 0;
+    for (int i = 0; i < postIndex; i++) {
+      globalIndex += allPosts[i].attachments.length;
+    }
+    globalIndex += attachmentIndex;
+
+    Navigator.push(
+      context,
+      MaterialPageRoute(
+        builder: (context) => MediaCarouselScreen(
+          initialIndex: globalIndex,
+          mediaItems: mediaItems,
+          loadMorePosts: loadMorePosts,
+          allPosts: allPosts,
+        ),
+      ),
+    );
+  }
+
 }
 
 class _PostHeader extends StatelessWidget {
@@ -123,116 +264,6 @@ void _sharePost(Post post) {
   Share.share(link);
 }
 
-class _PostContentSection extends StatelessWidget {
-  final Post post;
-
-  const _PostContentSection({required this.post});
-
-  @override
-  Widget build(BuildContext context) {
-    final imageAttachments = post.attachments
-      .where((a) => _isImageFile(a.link))
-      .toList();
-
-    return Column(
-      mainAxisSize: MainAxisSize.min,
-      crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        // Text Content
-        HtmlWidget(
-          post.content,
-          textStyle: Theme.of(context).textTheme.bodyMedium,
-          onTapUrl: (url) => _handleUrlLaunch(context, url),
-        ),
-
-        // Attachments Grid
-        if (imageAttachments.isNotEmpty)
-          LayoutBuilder(
-            builder: (context, constraints) {
-              return Column(
-                children: [
-                  const SizedBox(height: 12),
-                  GridView.builder(
-                    shrinkWrap: true,
-                    physics: const NeverScrollableScrollPhysics(),
-                    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
-                      crossAxisCount: _calculateColumnCount(constraints.maxWidth),
-                    ),
-                    itemCount: imageAttachments.length,
-                    itemBuilder: (context, index) => Padding(
-                      padding: const EdgeInsets.all(4),
-                      child: SmartImageContainer(
-                        imageUrl: imageAttachments[index].link,
-                        //onTap: () => _handleAttachmentTap(index),
-                      ),
-                    ),
-                  ),
-                  // Attachment links list
-                  ...post.attachments.map(
-                    (attachment) => _buildAttachmentLink(context,attachment),
-                  ),
-                ],
-              );
-            },
-          ),
-      ],
-    );
-  }
-
-  Widget _buildAttachmentLink(BuildContext context,Attachment attachment) {
-    return Padding(
-      padding: const EdgeInsets.only(top: 8),
-      child: InkWell(
-        onTap: () => _handleUrlLaunch(context, attachment.link),
-        child: Row(
-          children: [
-            const Icon(Icons.link, size: 16),
-            const SizedBox(width: 8),
-            Expanded(
-              child: Text(
-                attachment.name ?? _parseFileName(attachment.link),
-                style: const TextStyle(
-                  color: Colors.blue,
-                  decoration: TextDecoration.underline,
-                ),
-                overflow: TextOverflow.ellipsis,
-              ),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  int _calculateColumnCount(double availableWidth) {
-    return (availableWidth / 400).floor().clamp(1, 3);
-  }
-
-  String _parseFileName(String url) {
-    try {
-      return Uri.parse(url).pathSegments.last;
-    } catch (e) {
-      return 'Download';
-    }
-  }
-
-  bool _isImageFile(String url) {
-    try {
-      final uri = Uri.parse(url);
-      final path = uri.path.toLowerCase();
-      
-      final cleanPath = path.split('?').first.split('#').first;
-      const imageExtensions = {
-        'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg',
-        'tiff', 'heic', 'heif' 
-      };
-      return imageExtensions.any((ext) => cleanPath.endsWith('.$ext'));
-    } catch (e) {
-      return false; // Invalid URL format
-    }
-  }
-}
-
 Future<bool> _handleUrlLaunch(BuildContext context, String url) async {
   try {
       await launchUrl(
@@ -245,4 +276,4 @@ Future<bool> _handleUrlLaunch(BuildContext context, String url) async {
     );
   }
   return true;
-}
+}

+ 254 - 0
lib/pages/widgets/video_player_widget

@@ -0,0 +1,254 @@
+import 'dart:io';
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:media_kit/media_kit.dart';
+import 'package:media_kit_video/media_kit_video.dart';
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
+import 'package:path/path.dart' as path;
+import '../../main.dart';
+
+class VideoPlayerWidget extends StatefulWidget {
+  final String videoUrl;
+  
+  const VideoPlayerWidget({super.key, required this.videoUrl});
+
+  @override
+  State<VideoPlayerWidget> createState() => _VideoPlayerWidgetState();
+}
+
+class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
+  late final Player _player;
+  late final VideoController _controller;
+  bool _isInitialized = false;
+  bool _hasError = false;
+  bool _usingCache = false;
+  double _downloadProgress = 0;
+  double _bufferingProgress = 0;
+  bool _isBuffering = false;
+  File? _cachedFile;
+  StreamSubscription? _cacheSubscription;
+
+  @override
+  void initState() {
+    super.initState();
+    _initializePlayer();
+  }
+
+  Future<void> _initializePlayer() async {
+    try {
+      _cachedFile = await videoCacheManager.getSingleFile(widget.videoUrl);
+      _usingCache = _cachedFile != null;
+      
+      _player = Player();
+      _controller = VideoController(_player);
+      
+      final source = _cachedFile != null 
+          ? Media(_cachedFile!.path, httpHeaders: {}) 
+          : Media(widget.videoUrl);
+      
+      await _player.open(source);
+      await _player.setVolume(0);
+      
+      if (_cachedFile == null) {
+        _cacheVideo();
+      }
+
+      _player.stream.error.listen(_handlePlayerError);
+      _player.stream.buffering.listen(_handleBufferingUpdate);
+      
+    } catch (e) {
+      _handleError("Player init error: $e");
+    }
+  }
+
+  void _handleBufferingUpdate(bool isBuffering) {
+    setState(() {
+      _isBuffering = isBuffering;
+      _bufferingProgress = 0;
+      
+      if (!isBuffering && !_isInitialized && !_hasError) {
+        _isInitialized = true;
+      }
+    });
+  }
+
+  void _handlePlayerError(String error) {
+    _handleError("Player error: ${error})");
+  }
+
+  void _handleError(String message) {
+    print(message);
+    if (!_hasError) setState(() => _hasError = true);
+  }
+
+  Future<void> _cacheVideo() async {
+    final fileStream = videoCacheManager.getFileStream(
+      widget.videoUrl,
+      withProgress: true,
+    );
+
+    _cacheSubscription = fileStream.listen((fileResponse) {
+      if (fileResponse is DownloadProgress) {
+        setState(() => _downloadProgress = fileResponse.progress ?? 0);
+      } else if (fileResponse is FileInfo) {
+        setState(() {
+          _cachedFile = fileResponse.file;
+          _usingCache = true;
+          _downloadProgress = 0;
+        });
+      }
+    }, onError: (e) => print("Cache error: $e"));
+  }
+
+  void _retryPlayback() {
+    setState(() {
+      _hasError = false;
+      _isInitialized = false;
+      _downloadProgress = 0;
+      _bufferingProgress = 0;
+      _cacheSubscription?.cancel();
+    });
+    _initializePlayer();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      child: Stack(
+        children: [
+          // Video display
+          if (_isInitialized)
+            Video(controller: _controller),
+          
+          // Loading/buffering states
+          if (!_isInitialized || _isBuffering)
+            _buildLoadingState(),
+          
+          // Error state
+          if (_hasError)
+            _buildErrorState(),
+          
+          // Cache progress indicator
+          if (_downloadProgress > 0 && _downloadProgress < 1)
+            _buildCacheProgress(),
+            
+          // Buffering indicator
+          if (_isBuffering && _isInitialized)
+            _buildBufferingOverlay(),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildLoadingState() {
+    return Container(
+      color: Colors.black,
+      child: Center(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            CircularProgressIndicator(
+              value: _isBuffering ? _bufferingProgress : null,
+              valueColor: const AlwaysStoppedAnimation(Colors.white),
+            ),
+            const SizedBox(height: 16),
+            Text(
+              _usingCache ? 'Loading cached video' : 'Streaming video',
+              style: const TextStyle(color: Colors.white),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildBufferingOverlay() {
+    return Container(
+      color: Colors.black54,
+      child: Center(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            CircularProgressIndicator(
+              value: _bufferingProgress,
+              valueColor: const AlwaysStoppedAnimation(Colors.white),
+            ),
+            const SizedBox(height: 8),
+            const Text('Buffering...', style: TextStyle(color: Colors.white)),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildCacheProgress() {
+    return Positioned(
+      bottom: 16,
+      right: 16,
+      child: Container(
+        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+        decoration: BoxDecoration(
+          color: Colors.black.withOpacity(0.7),
+          borderRadius: BorderRadius.circular(20),
+        ),
+        child: Row(
+          children: [
+            const Icon(Icons.download, color: Colors.white, size: 20),
+            const SizedBox(width: 8),
+            Text(
+              '${(_downloadProgress * 100).toStringAsFixed(0)}%',
+              style: const TextStyle(color: Colors.white),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildErrorState() {
+    return Container(
+      color: Colors.black,
+      child: Center(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            const Icon(Icons.videocam_off, size: 64, color: Colors.white54),
+            const SizedBox(height: 16),
+            const Text('Playback Failed',
+              style: TextStyle(color: Colors.white, fontSize: 18)),
+            const SizedBox(height: 8),
+            Text(
+              _usingCache ? 'Cached file may be corrupted' : 'Network issue',
+              style: const TextStyle(color: Colors.white70),
+            ),
+            const SizedBox(height: 16),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.center,
+              children: [
+                ElevatedButton.icon(
+                  icon: const Icon(Icons.refresh),
+                  label: const Text('Retry'),
+                  onPressed: _retryPlayback,
+                ),
+                const SizedBox(width: 16),
+                if (!_usingCache)
+                  ElevatedButton.icon(
+                    icon: const Icon(Icons.download),
+                    label: const Text('Download'),
+                    onPressed: _cacheVideo,
+                  ),
+              ],
+            )
+          ],
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _player.dispose();
+    _cacheSubscription?.cancel();
+    super.dispose();
+  }
+}

+ 104 - 0
lib/pages/widgets/video_preview.dart

@@ -0,0 +1,104 @@
+import 'dart:typed_data';
+import 'package:flutter/material.dart';
+import 'package:veloe_kemono_party_flutter/video_thumbnail_generator';
+import '../../main.dart';
+
+class VideoPreview extends StatefulWidget {
+  final String videoUrl;
+  final VoidCallback onTap;
+  
+  const VideoPreview({
+    super.key,
+    required this.videoUrl,
+    required this.onTap,
+  });
+
+  @override
+  State<VideoPreview> createState() => _VideoPreviewState();
+}
+
+class _VideoPreviewState extends State<VideoPreview> {
+  Future<Uint8List?>? _thumbnailFuture;
+  bool _isGenerating = false;
+  bool _isCached = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _checkCacheStatus();
+    _loadThumbnail();
+  }
+
+  void _loadThumbnail() {
+    setState(() => _isGenerating = true);
+    _thumbnailFuture = VideoThumbnailGenerator.getFirstFrameThumbnail(widget.videoUrl)
+      .whenComplete(() => setState(() => _isGenerating = false));
+  }
+
+  Future<void> _checkCacheStatus() async {
+  final cachedFile = await videoCacheManager.getFileFromCache(widget.videoUrl);
+  if (cachedFile != null && mounted) {
+    setState(() => _isCached = true);
+  }
+}
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTap: widget.onTap,
+      child: FutureBuilder<Uint8List?>(
+        future: _thumbnailFuture,
+        builder: (context, snapshot) {
+          return AspectRatio(
+            aspectRatio: 16/9,
+            child: Stack(
+              fit: StackFit.expand,
+              children: [
+                // Thumbnail display
+                if (snapshot.hasData)
+                  Image.memory(snapshot.data!, fit: BoxFit.cover)
+                else if (_isGenerating)
+                  const Center(child: CircularProgressIndicator())
+                else
+                  Container(color: Colors.black),
+                
+                // Play button overlay
+                const Center(
+                  child: Icon(Icons.play_circle_filled,
+                    size: 64,
+                    color: Colors.white70,
+                  ),
+                ),
+                
+                // Video indicator
+                Positioned(
+                  bottom: 8,
+                  right: 8,
+                  child: Container(
+                    padding: const EdgeInsets.all(4),
+                    color: Colors.black54,
+                    child: const Text('VIDEO',
+                      style: TextStyle(color: Colors.white)),
+                  ),
+                ),
+                if (_isCached)
+                  Positioned(
+                    top: 8,
+                    right: 8,
+                    child: Container(
+                      padding: const EdgeInsets.all(4),
+                      decoration: const BoxDecoration(
+                        color: Colors.green,
+                        shape: BoxShape.circle,
+                      ),
+                      child: const Icon(Icons.download_done, size: 16, color: Colors.white),
+                    ),
+                  ),
+              ],
+            ),
+          );
+        },
+      ),
+    );
+  }
+}

+ 28 - 0
lib/video_thumbnail_generator

@@ -0,0 +1,28 @@
+import 'dart:async';
+import 'dart:typed_data';
+import 'package:video_thumbnail/video_thumbnail.dart';
+
+class VideoThumbnailGenerator {
+  static final Map<String, Uint8List> _thumbnailCache = {};
+
+  static Future<Uint8List?> getFirstFrameThumbnail(String videoUrl) async {
+    if (_thumbnailCache.containsKey(videoUrl)) {
+      return _thumbnailCache[videoUrl];
+    }
+
+    try {
+      final uint8list = await VideoThumbnail.thumbnailData(
+        video: videoUrl,
+        imageFormat: ImageFormat.JPEG,
+        maxWidth: 720,
+        quality: 80,
+      );
+      if (uint8list != null)
+        _thumbnailCache[videoUrl] = uint8list;
+      return uint8list;
+    } catch (e) {
+      print("Thumbnail generation error: $e");
+    }
+    return null;
+  }
+}

+ 8 - 0
linux/flutter/generated_plugin_registrant.cc

@@ -6,10 +6,18 @@
 
 #include "generated_plugin_registrant.h"
 
+#include <media_kit_video/media_kit_video_plugin.h>
 #include <url_launcher_linux/url_launcher_plugin.h>
+#include <volume_controller/volume_controller_plugin.h>
 
 void fl_register_plugins(FlPluginRegistry* registry) {
+  g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
+  media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
   g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
       fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
   url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+  g_autoptr(FlPluginRegistrar) volume_controller_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "VolumeControllerPlugin");
+  volume_controller_plugin_register_with_registrar(volume_controller_registrar);
 }

+ 2 - 0
linux/flutter/generated_plugins.cmake

@@ -3,7 +3,9 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
+  media_kit_video
   url_launcher_linux
+  volume_controller
 )
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST

+ 4 - 0
macos/Flutter/GeneratedPluginRegistrant.swift

@@ -7,24 +7,28 @@ import Foundation
 
 import audio_session
 import just_audio
+import media_kit_video
 import package_info_plus
 import path_provider_foundation
 import share_plus
 import sqflite_darwin
 import url_launcher_macos
 import video_player_avfoundation
+import volume_controller
 import wakelock_plus
 import webview_flutter_wkwebview
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
   AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
   JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
+  MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
   FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
   PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
   SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
   SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
   FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
+  VolumeControllerPlugin.register(with: registry.registrar(forPlugin: "VolumeControllerPlugin"))
   WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
   WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
 }

+ 113 - 1
pubspec.lock

@@ -17,6 +17,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "7.4.5"
+  archive:
+    dependency: transitive
+    description:
+      name: archive
+      sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.7"
   args:
     dependency: transitive
     description:
@@ -456,6 +464,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "4.1.2"
+  image:
+    dependency: transitive
+    description:
+      name: image
+      sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.5.4"
   intl:
     dependency: "direct main"
     description:
@@ -576,6 +592,30 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.11.1"
+  media_kit:
+    dependency: "direct main"
+    description:
+      name: media_kit
+      sha256: "48c10c3785df5d88f0eef970743f8c99b2e5da2b34b9d8f9876e598f62d9e776"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.0"
+  media_kit_libs_android_video:
+    dependency: "direct main"
+    description:
+      name: media_kit_libs_android_video
+      sha256: adff9b571b8ead0867f9f91070f8df39562078c0eb3371d88b9029a2d547d7b7
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.7"
+  media_kit_video:
+    dependency: "direct main"
+    description:
+      name: media_kit_video
+      sha256: a656a9463298c1adc64c57f2d012874f7f2900f0c614d9545a3e7b8bb9e2137b
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
   meta:
     dependency: transitive
     description:
@@ -704,6 +744,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "6.1.0"
+  photo_view:
+    dependency: "direct main"
+    description:
+      name: photo_view
+      sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.14.0"
   platform:
     dependency: transitive
     description:
@@ -728,8 +776,16 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.5.1"
-  provider:
+  posix:
     dependency: transitive
+    description:
+      name: posix
+      sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.2"
+  provider:
+    dependency: "direct main"
     description:
       name: provider
       sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
@@ -768,6 +824,30 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.28.0"
+  safe_local_storage:
+    dependency: transitive
+    description:
+      name: safe_local_storage
+      sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.1"
+  screen_brightness_android:
+    dependency: transitive
+    description:
+      name: screen_brightness_android
+      sha256: "6ba1b5812f66c64e9e4892be2d36ecd34210f4e0da8bdec6a2ea34f1aa42683e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  screen_brightness_platform_interface:
+    dependency: transitive
+    description:
+      name: screen_brightness_platform_interface
+      sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
   share_plus:
     dependency: "direct main"
     description:
@@ -957,6 +1037,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.4.0"
+  universal_platform:
+    dependency: transitive
+    description:
+      name: universal_platform
+      sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  uri_parser:
+    dependency: transitive
+    description:
+      name: uri_parser
+      sha256: ff4d2c720aca3f4f7d5445e23b11b2d15ef8af5ddce5164643f38ff962dcb270
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
   url_launcher:
     dependency: "direct main"
     description:
@@ -1101,6 +1197,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.3.5"
+  video_thumbnail:
+    dependency: "direct main"
+    description:
+      name: video_thumbnail
+      sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.5.6"
   vm_service:
     dependency: transitive
     description:
@@ -1109,6 +1213,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "14.3.1"
+  volume_controller:
+    dependency: transitive
+    description:
+      name: volume_controller
+      sha256: d75039e69c0d90e7810bfd47e3eedf29ff8543ea7a10392792e81f9bded7edf5
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.4.0"
   wakelock_plus:
     dependency: transitive
     description:

+ 7 - 1
pubspec.yaml

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 # In Windows, build-name is used as the major, minor, and patch parts
 # of the product and file versions while build-number is used as the build suffix.
-version: 1.0.0+1
+version: 1.0.5
 
 environment:
   sdk: ^3.7.2
@@ -44,6 +44,12 @@ dependencies:
   flutter_widget_from_html: ^0.10.0
   url_launcher: ^6.2.0
   share_plus: ^7.0.0
+  photo_view: ^0.14.0
+  provider: ^6.0.0
+  media_kit: ^1.1.0
+  media_kit_video: ^1.1.0
+  media_kit_libs_android_video: ^1.1.0
+  video_thumbnail: ^0.5.6
 
 dev_dependencies:
   flutter_test:

+ 6 - 0
windows/flutter/generated_plugin_registrant.cc

@@ -6,12 +6,18 @@
 
 #include "generated_plugin_registrant.h"
 
+#include <media_kit_video/media_kit_video_plugin_c_api.h>
 #include <share_plus/share_plus_windows_plugin_c_api.h>
 #include <url_launcher_windows/url_launcher_windows.h>
+#include <volume_controller/volume_controller_plugin_c_api.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
+  MediaKitVideoPluginCApiRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
   SharePlusWindowsPluginCApiRegisterWithRegistrar(
       registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
   UrlLauncherWindowsRegisterWithRegistrar(
       registry->GetRegistrarForPlugin("UrlLauncherWindows"));
+  VolumeControllerPluginCApiRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
 }

+ 2 - 0
windows/flutter/generated_plugins.cmake

@@ -3,8 +3,10 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
+  media_kit_video
   share_plus
   url_launcher_windows
+  volume_controller
 )
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST