diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 912134a64..4c0f37fce 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -35,3 +35,7 @@ DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db") THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails") IMAGES_PATH = "./images" + +# Videos storage +VIDEOS_PATH = "./videos" +THUMBNAIL_VIDEOS_PATH = "./videos/thumbnails" diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 15c1d374d..76149202b 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -457,6 +457,7 @@ def db_toggle_image_favourite_status(image_id: str) -> bool: finally: conn.close() + def db_get_image_by_id(image_id: str) -> Optional[dict]: """ Get a single image by ID with its favorite status. @@ -464,11 +465,14 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: conn = _connect() cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT id, path, folder_id, thumbnailPath, metadata, isTagged, isFavourite FROM images WHERE id = ? - """, (image_id,)) + """, + (image_id,), + ) row = cursor.fetchone() if not row: return None @@ -488,6 +492,7 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: finally: conn.close() + # ============================================================================ # MEMORIES FEATURE - Location and Time-based Queries # ============================================================================ diff --git a/backend/app/database/videos.py b/backend/app/database/videos.py new file mode 100644 index 000000000..cdff48b26 --- /dev/null +++ b/backend/app/database/videos.py @@ -0,0 +1,171 @@ +# Standard library imports +import sqlite3 +from typing import Any, List, Mapping, TypedDict, Union + +# App-specific imports +from app.config.settings import DATABASE_PATH +from app.logging.setup_logging import get_logger + +logger = get_logger(__name__) + +# Type aliases +VideoId = str +VideoPath = str +FolderId = Union[int, None] + + +class VideoRecord(TypedDict): + id: VideoId + path: VideoPath + folder_id: FolderId + thumbnailPath: Union[str, None] + metadata: Union[Mapping[str, Any], str] + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(DATABASE_PATH) + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def db_create_videos_table() -> None: + conn = _connect() + cursor = conn.cursor() + + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS videos ( + id TEXT PRIMARY KEY, + path VARCHAR UNIQUE, + folder_id INTEGER, + thumbnailPath TEXT, + metadata TEXT, + FOREIGN KEY (folder_id) REFERENCES folders(folder_id) ON DELETE SET NULL + ) + """ + ) + + conn.commit() + conn.close() + + +def db_insert_video(record: VideoRecord) -> bool: + conn = _connect() + cursor = conn.cursor() + + try: + cursor.execute( + """ + INSERT INTO videos (id, path, folder_id, thumbnailPath, metadata) + VALUES (:id, :path, :folder_id, :thumbnailPath, :metadata) + ON CONFLICT(path) DO UPDATE SET + folder_id=excluded.folder_id, + thumbnailPath=excluded.thumbnailPath, + metadata=excluded.metadata + """, + record, + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Error inserting video record: {e}") + conn.rollback() + return False + finally: + conn.close() + + +def db_get_all_videos() -> List[dict]: + """Get all videos from database, filtering out videos whose files no longer exist.""" + import os + + conn = _connect() + cursor = conn.cursor() + + try: + cursor.execute( + """ + SELECT id, path, folder_id, thumbnailPath, metadata + FROM videos + ORDER BY path + """ + ) + rows = cursor.fetchall() + videos = [] + deleted_ids = [] + + for v_id, path, folder_id, thumb, metadata in rows: + if not os.path.exists(path): + deleted_ids.append(v_id) + continue + + videos.append( + { + "id": v_id, + "path": path, + "folder_id": str(folder_id) if folder_id is not None else "", + "thumbnailPath": thumb, + "metadata": metadata, + } + ) + + if deleted_ids: + cursor.execute( + f"DELETE FROM videos WHERE id IN ({','.join('?' * len(deleted_ids))})", + deleted_ids, + ) + conn.commit() + logger.info(f"Removed {len(deleted_ids)} deleted video(s) from database") + + return videos + except Exception as e: + logger.error(f"Error fetching videos: {e}") + return [] + finally: + conn.close() + + +def db_get_video_by_path(path: str) -> dict | None: + """Get a video record by its file path.""" + conn = _connect() + cursor = conn.cursor() + try: + cursor.execute( + """ + SELECT id, path, folder_id, thumbnailPath, metadata + FROM videos + WHERE path = ? + """, + (path,), + ) + row = cursor.fetchone() + if row: + v_id, path, folder_id, thumb, metadata = row + return { + "id": v_id, + "path": path, + "folder_id": str(folder_id) if folder_id is not None else "", + "thumbnailPath": thumb, + "metadata": metadata, + } + return None + except Exception as e: + logger.error(f"Error fetching video by path {path}: {e}") + return None + finally: + conn.close() + + +def db_delete_video_by_id(video_id: VideoId) -> bool: + conn = _connect() + cursor = conn.cursor() + try: + cursor.execute("DELETE FROM videos WHERE id = ?", (video_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error deleting video {video_id}: {e}") + conn.rollback() + return False + finally: + conn.close() diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index 2a56a10a0..6178cc8af 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -42,6 +42,7 @@ image_util_process_folder_images, image_util_process_untagged_images, ) +from app.utils.videos import video_util_process_folder_videos from app.utils.model_bootstrap import ensure_ai_tagging_models from app.utils.face_clusters import cluster_util_face_clusters_sync from app.utils.API import API_util_restart_sync_microservice_watcher @@ -68,8 +69,9 @@ def post_folder_add_sequence(folder_path: str, folder_id: int): folder_data.append((folder_path_from_db, folder_id_from_db, False)) logger.info(f"Add folder: {folder_data}") - # Process images in all folders + # Process images and videos in all folders image_util_process_folder_images(folder_data) + video_util_process_folder_videos(folder_data) # Restart sync microservice watcher after processing images API_util_restart_sync_microservice_watcher() @@ -116,8 +118,9 @@ def post_sync_folder_sequence( folder_data.append((added_folder_path, added_folder_id, False)) logger.info(f"Sync folder: {folder_data}") - # Process images in all folders + # Process images and videos in all folders image_util_process_folder_images(folder_data) + video_util_process_folder_videos(folder_data) image_util_process_untagged_images() cluster_util_face_clusters_sync() diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index f00b0a941..3741e13f6 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -106,15 +106,15 @@ def toggle_favourite(req: ToggleFavouriteRequest): success = db_toggle_image_favourite_status(image_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Image not found or failed to toggle" + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found or failed to toggle", ) # Fetch updated status to return image = db_get_image_by_id(image_id) if not image: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Image not found after toggle" + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found after toggle", ) return { "success": True, @@ -126,10 +126,11 @@ def toggle_favourite(req: ToggleFavouriteRequest): except Exception as e: logger.error(f"error in /toggle-favourite route: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {e}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {e}", ) + class ImageInfoResponse(BaseModel): id: str path: str diff --git a/backend/app/routes/videos.py b/backend/app/routes/videos.py new file mode 100644 index 000000000..14b4779f2 --- /dev/null +++ b/backend/app/routes/videos.py @@ -0,0 +1,92 @@ +from fastapi import APIRouter, HTTPException, status +from typing import List +from app.database.videos import db_get_all_videos +from app.schemas.videos import GetAllVideosResponse, ErrorResponse, VideoData +from app.utils.images import image_util_parse_metadata +from app.utils.videos import video_util_process_folder_videos +from app.database.folders import db_get_all_folder_details + +router = APIRouter() + + +def _map_videos_to_response(videos: List[dict]) -> List[VideoData]: + """Helper function to map database video dicts to VideoData objects.""" + return [ + VideoData( + id=v["id"], + path=v["path"], + folder_id=v.get("folder_id"), + thumbnailPath=v["thumbnailPath"], + metadata=image_util_parse_metadata(v.get("metadata")), + ) + for v in videos + ] + + +@router.get( + "/", + response_model=GetAllVideosResponse, + responses={500: {"model": ErrorResponse}}, +) +def get_all_videos(): + try: + videos = db_get_all_videos() + data = _map_videos_to_response(videos) + return GetAllVideosResponse( + success=True, message=f"Retrieved {len(data)} videos", data=data + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal server error", message=str(e) + ).model_dump(), + ) + + +@router.post( + "/scan", + response_model=GetAllVideosResponse, + responses={500: {"model": ErrorResponse}}, +) +async def scan_videos_from_folders(): + try: + rows = db_get_all_folder_details() + folder_data = [(r[1], r[0], False) for r in rows] + video_util_process_folder_videos(folder_data) + videos = db_get_all_videos() + data = _map_videos_to_response(videos) + return GetAllVideosResponse( + success=True, message=f"Scanned {len(folder_data)} folder(s)", data=data + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal server error", message=str(e) + ).model_dump(), + ) + + +@router.post( + "/cleanup", + response_model=GetAllVideosResponse, + responses={500: {"model": ErrorResponse}}, +) +async def cleanup_deleted_videos(): + """Clean up database entries for videos that no longer exist on disk.""" + try: + videos = db_get_all_videos() + data = _map_videos_to_response(videos) + return GetAllVideosResponse( + success=True, + message=f"Cleanup complete. {len(data)} video(s) remain.", + data=data, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ErrorResponse( + success=False, error="Internal server error", message=str(e) + ).model_dump(), + ) diff --git a/backend/app/schemas/videos.py b/backend/app/schemas/videos.py new file mode 100644 index 000000000..6238e2dc9 --- /dev/null +++ b/backend/app/schemas/videos.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel +from typing import Optional, List, Mapping, Any + + +class VideoMetadata(BaseModel): + name: str + date_created: Optional[str] = None + width: int + height: int + duration: float + file_location: str + file_size: int + item_type: str + + +class VideoData(BaseModel): + id: str + path: str + folder_id: Optional[str] = None + thumbnailPath: Optional[str] = None + metadata: Mapping[str, Any] | VideoMetadata + + +class GetAllVideosResponse(BaseModel): + success: bool + message: str + data: List[VideoData] + + +class ErrorResponse(BaseModel): + success: bool = False + message: str + error: str diff --git a/backend/app/utils/videos.py b/backend/app/utils/videos.py new file mode 100644 index 000000000..076d933c4 --- /dev/null +++ b/backend/app/utils/videos.py @@ -0,0 +1,126 @@ +import os +import uuid +import json +import datetime +import cv2 +from typing import Dict, List, Tuple +from pathlib import Path +from app.config.settings import VIDEOS_PATH +from app.database.videos import db_insert_video +from app.logging.setup_logging import get_logger + +logger = get_logger(__name__) + + +def video_util_ensure_dirs() -> None: + os.makedirs(VIDEOS_PATH, exist_ok=True) + + +def video_util_extract_metadata(video_path: str) -> Dict: + try: + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise RuntimeError("Unable to open video") + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + fps = float(cap.get(cv2.CAP_PROP_FPS) or 0) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + duration = frame_count / fps if fps > 0 else 0 + cap.release() + + stats = os.stat(video_path) + return { + "name": os.path.basename(video_path), + "date_created": datetime.datetime.fromtimestamp(stats.st_mtime).isoformat(), + "width": width, + "height": height, + "duration": duration, + "file_location": video_path, + "file_size": stats.st_size, + "item_type": "video/mp4", # best effort; actual type resolved by player + } + except Exception: + return { + "name": os.path.basename(video_path), + "date_created": None, + "width": 0, + "height": 0, + "duration": 0, + "file_location": video_path, + "file_size": 0, + "item_type": "video", + } + + +def video_util_register_file( + file_path: str, folder_id: int | None = None +) -> str | None: + """Register a video file in the database.""" + from app.database.videos import db_get_video_by_path + + video_util_ensure_dirs() + abs_file_path = os.path.abspath(file_path) + + existing = db_get_video_by_path(abs_file_path) + if existing: + logger.info(f"Video already registered: {abs_file_path}") + return existing["id"] + + video_id = str(uuid.uuid4()) + metadata = video_util_extract_metadata(abs_file_path) + + ok = db_insert_video( + { + "id": video_id, + "path": abs_file_path, + "folder_id": folder_id, + "thumbnailPath": None, + "metadata": json.dumps(metadata), + } + ) + return video_id if ok else None + + +def video_util_is_valid_video(file_path: str) -> bool: + ext = Path(file_path).suffix.lower() + allowed = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv", ".mpeg", ".mpg"} + return ext in allowed + + +def video_util_get_videos_from_folder( + folder_path: str, recursive: bool = True +) -> List[str]: + videos: List[str] = [] + if recursive: + for root, _, files in os.walk(folder_path): + for f in files: + p = os.path.join(root, f) + if video_util_is_valid_video(p): + videos.append(p) + else: + try: + for f in os.listdir(folder_path): + p = os.path.join(folder_path, f) + if os.path.isfile(p) and video_util_is_valid_video(p): + videos.append(p) + except OSError: + pass + return videos + + +essential_tuple = Tuple[str, int, bool] + + +def video_util_process_folder_videos(folder_data: List[essential_tuple]) -> bool: + try: + video_util_ensure_dirs() + for path, folder_id, recursive in folder_data: + file_list = video_util_get_videos_from_folder(path, recursive) + for fp in file_list: + try: + video_util_register_file(fp, folder_id) + except Exception: + continue + return True + except Exception: + return False diff --git a/backend/main.py b/backend/main.py index af10bba49..854e87602 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,10 +21,13 @@ from app.database.albums import db_create_album_images_table from app.database.folders import db_create_folders_table from app.database.metadata import db_create_metadata_table +from app.database.videos import db_create_videos_table +from app.utils.microservice import microservice_util_start_sync_service from app.routes.folders import router as folders_router from app.routes.albums import router as albums_router from app.routes.images import router as images_router +from app.routes.videos import router as videos_router from app.routes.face_clusters import router as face_clusters_router from app.routes.user_preferences import router as user_preferences_router from app.routes.memories import router as memories_router @@ -61,6 +64,8 @@ async def lifespan(app: FastAPI): db_create_albums_table() db_create_album_images_table() db_create_metadata_table() + microservice_util_start_sync_service() + db_create_videos_table() # Create ProcessPoolExecutor and attach it to app.state app.state.executor = ProcessPoolExecutor(max_workers=1) @@ -139,6 +144,7 @@ async def root(): app.include_router(folders_router, prefix="/folders", tags=["Folders"]) app.include_router(albums_router, prefix="/albums", tags=["Albums"]) app.include_router(images_router, prefix="/images", tags=["Images"]) +app.include_router(videos_router, prefix="/videos", tags=["Videos"]) app.include_router( face_clusters_router, prefix="/face-clusters", tags=["Face Clusters"] ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a6a8fc7a..7586374dd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "framer-motion": "^11.16.3", "ldrs": "^1.0.2", "lucide-react": "^0.513.0", + "plyr": "^3.8.3", "react": "19.1.0", "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", @@ -6950,6 +6951,16 @@ "node": ">=18" } }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", @@ -7075,6 +7086,11 @@ "devOptional": true, "license": "MIT" }, + "node_modules/custom-event-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", + "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -11168,6 +11184,11 @@ "dev": true, "license": "MIT" }, + "node_modules/loadjs": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", + "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==" + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -11904,6 +11925,18 @@ "node": ">=8" } }, + "node_modules/plyr": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz", + "integrity": "sha512-0+iI5uw0WRvtKBpgPCkmQQv7ucHVQKTEo6UFJjgJ8cy/JZhy0dQqshHQVitHXV6l2O3MzhgnuvQ95VSkWcWeSw==", + "dependencies": { + "core-js": "^3.45.1", + "custom-event-polyfill": "^1.0.7", + "loadjs": "^4.3.0", + "rangetouch": "^2.0.1", + "url-polyfill": "^1.1.13" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12199,6 +12232,11 @@ ], "license": "MIT" }, + "node_modules/rangetouch": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz", + "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==" + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -13960,6 +13998,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index a17f67597..f118b9503 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,6 +53,7 @@ "framer-motion": "^11.16.3", "ldrs": "^1.0.2", "lucide-react": "^0.513.0", + "plyr": "^3.8.3", "react": "19.1.0", "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", diff --git a/frontend/src/api/api-functions/index.ts b/frontend/src/api/api-functions/index.ts index 4e22ef925..f8b89414a 100644 --- a/frontend/src/api/api-functions/index.ts +++ b/frontend/src/api/api-functions/index.ts @@ -1,6 +1,7 @@ // Export all API functions export * from './face_clusters'; export * from './images'; +export * from './videos'; export * from './folders'; export * from './user_preferences'; export * from './health'; diff --git a/frontend/src/api/api-functions/videos.ts b/frontend/src/api/api-functions/videos.ts new file mode 100644 index 000000000..62993f811 --- /dev/null +++ b/frontend/src/api/api-functions/videos.ts @@ -0,0 +1,28 @@ +import { videosEndpoints } from '../apiEndpoints'; +import { apiClient } from '../axiosConfig'; +import { APIResponse } from '@/types/API'; + +export const fetchAllVideos = async (): Promise => { + const response = await apiClient.get( + videosEndpoints.getAllVideos, + ); + return response.data; +}; + +export const uploadVideos = async (files: File[]): Promise => { + const form = new FormData(); + files.forEach((f) => form.append('files', f)); + const response = await apiClient.post( + videosEndpoints.upload, + form, + { + headers: { 'Content-Type': 'multipart/form-data' }, + }, + ); + return response.data; +}; + +export const scanVideos = async (): Promise => { + const response = await apiClient.post(videosEndpoints.scan); + return response.data; +}; diff --git a/frontend/src/api/apiEndpoints.ts b/frontend/src/api/apiEndpoints.ts index f0e749197..10bc07318 100644 --- a/frontend/src/api/apiEndpoints.ts +++ b/frontend/src/api/apiEndpoints.ts @@ -3,6 +3,12 @@ export const imagesEndpoints = { setFavourite: '/images/toggle-favourite', }; +export const videosEndpoints = { + getAllVideos: '/videos/', + upload: '/videos/upload', + scan: '/videos/scan', +}; + export const faceClustersEndpoints = { getAllClusters: '/face-clusters/', searchForFaces: '/face-clusters/face-search?input_type=path', diff --git a/frontend/src/components/Media/VideoCard.tsx b/frontend/src/components/Media/VideoCard.tsx new file mode 100644 index 000000000..7a5db2af9 --- /dev/null +++ b/frontend/src/components/Media/VideoCard.tsx @@ -0,0 +1,52 @@ +import { AspectRatio } from '@/components/ui/aspect-ratio'; +import { cn } from '@/lib/utils'; +import { Play } from 'lucide-react'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { Video } from '@/types/Media'; + +interface VideoCardProps { + video: Video; + className?: string; + onClick?: () => void; +} + +export function VideoCard({ video, className, onClick }: VideoCardProps) { + return ( +
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx b/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx deleted file mode 100644 index 84c8f7737..000000000 --- a/frontend/src/components/VideoPlayer/NetflixStylePlayer.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import type React from 'react'; -import { useState, useRef, useEffect } from 'react'; -import { - Play, - Pause, - Maximize2, - Minimize2, - Rewind, - FastForward, - Volume2, - VolumeX, -} from 'lucide-react'; -import { Slider } from '../../components/ui/Slider'; - -interface NetflixStylePlayerProps { - videoSrc: string; - title: string; - description: string; -} - -export default function NetflixStylePlayer({ - videoSrc, -}: NetflixStylePlayerProps) { - const [isPlaying, setIsPlaying] = useState(false); - const [progress, setProgress] = useState(0); - const [volume, setVolume] = useState(1); - const [isMuted, setIsMuted] = useState(false); - const [showControls, setShowControls] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const videoRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - let timeout: NodeJS.Timeout; - const showControlsTemporarily = () => { - setShowControls(true); - clearTimeout(timeout); - timeout = setTimeout(() => setShowControls(false), 3000); - }; - - const container = containerRef.current; - if (container) { - container.addEventListener('mousemove', showControlsTemporarily); - container.addEventListener('mouseenter', showControlsTemporarily); - } - - return () => { - if (container) { - container.removeEventListener('mousemove', showControlsTemporarily); - container.removeEventListener('mouseenter', showControlsTemporarily); - } - clearTimeout(timeout); - }; - }, []); - - const formatTime = (timeInSeconds: number) => { - const hours = Math.floor(timeInSeconds / 3600); - const minutes = Math.floor((timeInSeconds % 3600) / 60); - const seconds = Math.floor(timeInSeconds % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - }; - - const togglePlay = () => { - if (videoRef.current) { - isPlaying ? videoRef.current.pause() : videoRef.current.play(); - setIsPlaying(!isPlaying); - } - }; - - const handleProgress = () => { - if (videoRef.current) { - setProgress( - (videoRef.current.currentTime / videoRef.current.duration) * 100, - ); - } - }; - - const handleProgressBarClick = (e: React.MouseEvent) => { - if (videoRef.current) { - const progressBar = e.currentTarget; - const clickPosition = - (e.clientX - progressBar.getBoundingClientRect().left) / - progressBar.offsetWidth; - videoRef.current.currentTime = clickPosition * videoRef.current.duration; - } - }; - - const toggleFullScreen = () => { - if (!document.fullscreenElement) { - containerRef.current?.requestFullscreen(); - } else { - document.exitFullscreen(); - } - setIsFullscreen(!isFullscreen); - }; - - const skipTime = (seconds: number) => { - if (videoRef.current) { - videoRef.current.currentTime += seconds; - } - }; - - const handleVolumeChange = (newVolume: number[]) => { - if (videoRef.current) { - const volumeValue = newVolume[0]; - videoRef.current.volume = volumeValue; - setVolume(volumeValue); - setIsMuted(volumeValue === 0); - } - }; - - const toggleMute = () => { - if (videoRef.current) { - const newMuteState = !isMuted; - videoRef.current.muted = newMuteState; - setIsMuted(newMuteState); - } - }; - - return ( -
- {/* Clickable play/pause area above progress bar */} -
-
- - {/* Progress Bar */} -
-
-
-
-
- - {/* Controls */} -
-
- - - -
- {formatTime(videoRef.current?.currentTime ?? 0) + - ' / ' + - formatTime(videoRef.current?.duration ?? 0)} -
-
- - {/* Volume and Fullscreen */} -
- - - -
-
-
- ); -} diff --git a/frontend/src/components/VideoPlayer/PlyrPlayer.tsx b/frontend/src/components/VideoPlayer/PlyrPlayer.tsx new file mode 100644 index 000000000..1398b5eda --- /dev/null +++ b/frontend/src/components/VideoPlayer/PlyrPlayer.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef } from 'react'; +import 'plyr/dist/plyr.css'; + +interface PlyrPlayerProps { + src: string; + title?: string; + poster?: string; +} + +export default function PlyrPlayer({ src, title, poster }: PlyrPlayerProps) { + const ref = useRef(null); + const playerRef = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + let mounted = true; + + // Dynamically import Plyr to avoid TypeScript module issues + import('plyr') + .then((PlyrModule) => { + if (!mounted || !ref.current) return; + + const Plyr = (PlyrModule as any).default || PlyrModule; + + // Destroy previous instance if any + if (playerRef.current) { + playerRef.current.destroy(); + } + + // Initialize Plyr + try { + playerRef.current = new Plyr(ref.current, { + controls: [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'mute', + 'volume', + 'captions', + 'settings', + 'pip', + 'airplay', + 'fullscreen', + ], + settings: ['captions', 'quality', 'speed', 'loop'], + // Enable preview thumbnails on hover + previewThumbnails: { enabled: false }, // Set to true if you have thumbnail sprites + // Match demo behavior + tooltips: { controls: true, seek: true }, + keyboard: { focused: true, global: false }, + invertTime: false, + // Quality options + quality: { + default: 720, + options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240], + }, + // Speed options + speed: { + selected: 1, + options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], + }, + // Ratio + ratio: '16:9', + // Auto-hide controls + hideControls: true, + // Click to play + clickToPlay: true, + }); + } catch (error) { + console.error('Failed to initialize Plyr:', error); + } + }) + .catch((error) => { + console.error('Failed to load Plyr module:', error); + }); + + return () => { + mounted = false; + if (playerRef.current) { + playerRef.current.destroy(); + } + playerRef.current = null; + }; + }, [src, title]); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 28691b2c7..965721e2f 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/frontend/src/pages/VideosPage/Videos.tsx b/frontend/src/pages/VideosPage/Videos.tsx index 0e07717b1..6f8039b41 100644 --- a/frontend/src/pages/VideosPage/Videos.tsx +++ b/frontend/src/pages/VideosPage/Videos.tsx @@ -1,9 +1,146 @@ +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { fetchAllVideos, scanVideos } from '@/api/api-functions'; +import { APIResponse } from '@/types/API'; +import { Video } from '@/types/Media'; +import { VideoCard } from '@/components/Media/VideoCard'; +import PlyrPlayer from '@/components/VideoPlayer/PlyrPlayer'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Calendar, ArrowDownAZ, RefreshCw } from 'lucide-react'; import { LoadingScreen } from '@/components/ui/LoadingScreen/LoadingScreen'; +import { convertFileSrc } from '@tauri-apps/api/core'; const Videos: React.FC = () => { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['videos'], + queryFn: fetchAllVideos, + }); + const [videos, setVideos] = useState([]); + const [active, setActive] = useState