Skip to content

Commit 20ee500

Browse files
committed
added the features ui
1 parent d897e26 commit 20ee500

7 files changed

Lines changed: 956 additions & 89 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.bytecoder.vplay.backend.utils
2+
3+
import android.content.Context
4+
import android.database.ContentObserver
5+
import android.net.Uri
6+
import android.os.Handler
7+
import android.os.Looper
8+
import android.provider.MediaStore
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.Job
12+
import kotlinx.coroutines.delay
13+
import kotlinx.coroutines.launch
14+
15+
class MediaStoreObserver(
16+
private val context: Context,
17+
private val onMediaChanged: (MediaType) -> Unit
18+
) : ContentObserver(Handler(Looper.getMainLooper())) {
19+
20+
enum class MediaType {
21+
AUDIO, VIDEO, BOTH
22+
}
23+
24+
private val scope = CoroutineScope(Dispatchers.Main + Job())
25+
private var refreshJob: Job? = null
26+
27+
private val audioUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
28+
private val videoUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
29+
30+
fun startObserving() {
31+
context.contentResolver.registerContentObserver(audioUri, true, this)
32+
context.contentResolver.registerContentObserver(videoUri, true, this)
33+
}
34+
35+
fun stopObserving() {
36+
context.contentResolver.unregisterContentObserver(this)
37+
refreshJob?.cancel()
38+
}
39+
40+
override fun onChange(selfChange: Boolean, uri: Uri?) {
41+
super.onChange(selfChange, uri)
42+
43+
// Debounce rapid changes - only refresh after changes stop for 2 seconds
44+
refreshJob?.cancel()
45+
refreshJob = scope.launch {
46+
delay(2000) // Wait 2 seconds after last change
47+
48+
val mediaType = when {
49+
uri?.toString()?.contains("audio") == true -> MediaType.AUDIO
50+
uri?.toString()?.contains("video") == true -> MediaType.VIDEO
51+
else -> MediaType.BOTH
52+
}
53+
54+
onMediaChanged(mediaType)
55+
}
56+
}
57+
58+
override fun onChange(selfChange: Boolean) {
59+
onChange(selfChange, null)
60+
}
61+
}

app/src/main/java/com/bytecoder/vplay/frontend/ui/player/AudioPlayerScreen.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Spacer
2121
import kotlinx.coroutines.launch
2222
import androidx.compose.runtime.rememberCoroutineScope
2323
import com.bytecoder.vplay.backend.managers.MusicLibraryManager
24+
import com.bytecoder.vplay.backend.managers.PlayerManager
2425
import androidx.compose.foundation.layout.aspectRatio
2526
import androidx.compose.foundation.layout.fillMaxSize
2627
import androidx.compose.foundation.layout.fillMaxWidth
@@ -148,6 +149,13 @@ fun AudioPlayerScreen(
148149
)
149150
)
150151

152+
// Sync queue changes with PlayerManager
153+
LaunchedEffect(queue, currentIndex) {
154+
if (queue.isNotEmpty() && currentIndex >= 0) {
155+
PlayerManager.rebuildFromQueue(context, queue, currentIndex)
156+
}
157+
}
158+
151159
VPlayTheme {
152160
BottomSheetScaffold(
153161
scaffoldState = scaffoldState,

0 commit comments

Comments
 (0)