1+ package com.bytecoder.vplay.backend.managers
2+
3+ import android.content.Context
4+ import android.content.pm.PackageManager
5+ import android.graphics.Bitmap
6+ import android.media.MediaMetadataRetriever
7+ import android.provider.MediaStore
8+ import android.Manifest
9+ import androidx.core.content.ContextCompat
10+ import kotlinx.coroutines.Dispatchers
11+ import kotlinx.coroutines.withContext
12+ import java.io.File
13+ import java.io.FileOutputStream
14+ import java.io.IOException
15+
16+ data class VideoFile (
17+ val id : String ,
18+ val title : String ,
19+ val filePath : String ,
20+ val duration : Long ,
21+ val size : Long ,
22+ val width : Int ,
23+ val height : Int ,
24+ val dateAdded : Long ,
25+ val dateModified : Long ,
26+ val mimeType : String ,
27+ val resolution : String = " ${width} x${height} " ,
28+ val displayName : String = title,
29+ val thumbnailPath : String? = null
30+ )
31+
32+ sealed class VideoScanResult {
33+ data class Success (val videos : List <VideoFile >) : VideoScanResult()
34+ data class Error (val message : String , val cause : Throwable ? = null ) : VideoScanResult()
35+ object PermissionDenied : VideoScanResult()
36+ }
37+
38+ class VideoLibraryManager (private val context : Context ) {
39+
40+ suspend fun scanVideosFromDevice (): VideoScanResult = withContext(Dispatchers .IO ) {
41+ // Check for storage permissions
42+ if (! hasStoragePermission()) {
43+ return @withContext VideoScanResult .PermissionDenied
44+ }
45+
46+ try {
47+ val videos = performVideoScan()
48+ VideoScanResult .Success (videos)
49+ } catch (e: SecurityException ) {
50+ VideoScanResult .Error (" Storage permission denied" , e)
51+ } catch (e: Exception ) {
52+ VideoScanResult .Error (" Failed to scan videos: ${e.message} " , e)
53+ }
54+ }
55+
56+ private fun hasStoragePermission (): Boolean {
57+ return if (android.os.Build .VERSION .SDK_INT >= android.os.Build .VERSION_CODES .TIRAMISU ) {
58+ // Android 13+ uses READ_MEDIA_VIDEO
59+ ContextCompat .checkSelfPermission(
60+ context,
61+ Manifest .permission.READ_MEDIA_VIDEO
62+ ) == PackageManager .PERMISSION_GRANTED
63+ } else {
64+ // Below Android 13 uses READ_EXTERNAL_STORAGE
65+ ContextCompat .checkSelfPermission(
66+ context,
67+ Manifest .permission.READ_EXTERNAL_STORAGE
68+ ) == PackageManager .PERMISSION_GRANTED
69+ }
70+ }
71+
72+ private suspend fun performVideoScan (): List <VideoFile > = withContext(Dispatchers .IO ) {
73+ val videos = mutableListOf<VideoFile >()
74+
75+ val projection = arrayOf(
76+ MediaStore .Video .Media ._ID ,
77+ MediaStore .Video .Media .TITLE ,
78+ MediaStore .Video .Media .DISPLAY_NAME ,
79+ MediaStore .Video .Media .DATA ,
80+ MediaStore .Video .Media .DURATION ,
81+ MediaStore .Video .Media .SIZE ,
82+ MediaStore .Video .Media .WIDTH ,
83+ MediaStore .Video .Media .HEIGHT ,
84+ MediaStore .Video .Media .DATE_ADDED ,
85+ MediaStore .Video .Media .DATE_MODIFIED ,
86+ MediaStore .Video .Media .MIME_TYPE
87+ )
88+
89+ val selection = " ${MediaStore .Video .Media .MIME_TYPE } LIKE 'video/%'"
90+ val sortOrder = " ${MediaStore .Video .Media .TITLE } ASC"
91+
92+ context.contentResolver.query(
93+ MediaStore .Video .Media .EXTERNAL_CONTENT_URI ,
94+ projection,
95+ selection,
96+ null ,
97+ sortOrder
98+ )?.use { cursor ->
99+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media ._ID )
100+ val titleColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .TITLE )
101+ val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .DISPLAY_NAME )
102+ val dataColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .DATA )
103+ val durationColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .DURATION )
104+ val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .SIZE )
105+ val widthColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .WIDTH )
106+ val heightColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .HEIGHT )
107+ val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .DATE_ADDED )
108+ val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .DATE_MODIFIED )
109+ val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore .Video .Media .MIME_TYPE )
110+
111+ while (cursor.moveToNext()) {
112+ try {
113+ val id = cursor.getLong(idColumn)
114+ val title = cursor.getString(titleColumn) ? : " Unknown Video"
115+ val displayName = cursor.getString(displayNameColumn) ? : title
116+ val filePath = cursor.getString(dataColumn) ? : continue
117+ val duration = cursor.getLong(durationColumn)
118+ val size = cursor.getLong(sizeColumn)
119+ val width = cursor.getInt(widthColumn)
120+ val height = cursor.getInt(heightColumn)
121+ val dateAdded = cursor.getLong(dateAddedColumn) * 1000
122+ val dateModified = cursor.getLong(dateModifiedColumn) * 1000
123+ val mimeType = cursor.getString(mimeTypeColumn) ? : " video/mp4"
124+
125+ // Validate file exists and is readable
126+ val file = File (filePath)
127+ if (! file.exists() || ! file.canRead()) continue
128+
129+ // Skip corrupted or very small files
130+ if (size < 1024 ) continue // Skip files smaller than 1KB
131+
132+ // Generate thumbnail
133+ val thumbnailPath = generateVideoThumbnail(filePath, id.toString())
134+
135+ val video = VideoFile (
136+ id = id.toString(),
137+ title = title,
138+ filePath = filePath,
139+ duration = duration,
140+ size = size,
141+ width = width,
142+ height = height,
143+ dateAdded = dateAdded,
144+ dateModified = dateModified,
145+ mimeType = mimeType,
146+ thumbnailPath = thumbnailPath
147+ )
148+
149+ videos.add(video)
150+ } catch (e: Exception ) {
151+ // Log individual file errors but continue processing
152+ continue
153+ }
154+ }
155+ }
156+
157+ return @withContext videos
158+ }
159+
160+ fun formatDuration (durationMs : Long ): String {
161+ val seconds = (durationMs / 1000 ) % 60
162+ val minutes = (durationMs / (1000 * 60 )) % 60
163+ val hours = (durationMs / (1000 * 60 * 60 ))
164+
165+ return if (hours > 0 ) {
166+ String .format(" %d:%02d:%02d" , hours, minutes, seconds)
167+ } else {
168+ String .format(" %d:%02d" , minutes, seconds)
169+ }
170+ }
171+
172+ fun formatFileSize (bytes : Long ): String {
173+ val kilobyte = 1024
174+ val megabyte = kilobyte * 1024
175+ val gigabyte = megabyte * 1024
176+
177+ return when {
178+ bytes >= gigabyte -> String .format(" %.1f GB" , bytes.toDouble() / gigabyte)
179+ bytes >= megabyte -> String .format(" %.1f MB" , bytes.toDouble() / megabyte)
180+ bytes >= kilobyte -> String .format(" %.1f KB" , bytes.toDouble() / kilobyte)
181+ else -> " $bytes bytes"
182+ }
183+ }
184+
185+ private suspend fun generateVideoThumbnail (videoPath : String , videoId : String ): String? = withContext(Dispatchers .IO ) {
186+ try {
187+ val thumbnailsDir = File (context.cacheDir, " video_thumbnails" )
188+ if (! thumbnailsDir.exists()) {
189+ thumbnailsDir.mkdirs()
190+ }
191+
192+ val thumbnailFile = File (thumbnailsDir, " thumb_$videoId .jpg" )
193+
194+ // Return existing thumbnail if it exists
195+ if (thumbnailFile.exists()) {
196+ return @withContext thumbnailFile.absolutePath
197+ }
198+
199+ val retriever = MediaMetadataRetriever ()
200+ try {
201+ retriever.setDataSource(videoPath)
202+
203+ // Get thumbnail at 1 second into the video (or start if video is shorter)
204+ val timeUs = 1_000_000L // 1 second in microseconds
205+ val bitmap = retriever.getFrameAtTime(timeUs, MediaMetadataRetriever .OPTION_CLOSEST_SYNC )
206+
207+ if (bitmap != null ) {
208+ // Scale down the bitmap to save space
209+ val scaledBitmap = Bitmap .createScaledBitmap(bitmap, 160 , 120 , true )
210+
211+ val outputStream = FileOutputStream (thumbnailFile)
212+ scaledBitmap.compress(Bitmap .CompressFormat .JPEG , 80 , outputStream)
213+ outputStream.close()
214+
215+ bitmap.recycle()
216+ scaledBitmap.recycle()
217+
218+ return @withContext thumbnailFile.absolutePath
219+ }
220+ } catch (e: Exception ) {
221+ e.printStackTrace()
222+ } finally {
223+ try {
224+ retriever.release()
225+ } catch (e: Exception ) {
226+ e.printStackTrace()
227+ }
228+ }
229+ } catch (e: Exception ) {
230+ e.printStackTrace()
231+ }
232+
233+ return @withContext null
234+ }
235+
236+ fun clearThumbnailCache () {
237+ try {
238+ val thumbnailsDir = File (context.cacheDir, " video_thumbnails" )
239+ if (thumbnailsDir.exists()) {
240+ thumbnailsDir.listFiles()?.forEach { file ->
241+ file.delete()
242+ }
243+ }
244+ } catch (e: Exception ) {
245+ e.printStackTrace()
246+ }
247+ }
248+ }
0 commit comments