mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Collapse shared modules into data domain and ui-common
This commit is contained in:
45
domain/build.gradle.kts
Normal file
45
domain/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "hu.bbara.purefin.core.download"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":data"))
|
||||
implementation(libs.hilt)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.medi3.exoplayer)
|
||||
implementation(libs.medi3.exoplayer.hls)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.datastore)
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DownloadControllerModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMediaDownloadController(
|
||||
impl: MediaDownloadManager
|
||||
): MediaDownloadController
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.CacheEvictor
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal object DownloadModuleFactories {
|
||||
fun createDownloadCacheEvictor(): CacheEvictor {
|
||||
return NoOpCacheEvictor()
|
||||
}
|
||||
|
||||
fun newPhonePlaybackDataSourceFactory(
|
||||
upstreamDataSourceFactory: DataSource.Factory
|
||||
): CacheDataSource.Factory {
|
||||
return CacheDataSource.Factory()
|
||||
.setUpstreamDataSourceFactory(upstreamDataSourceFactory)
|
||||
.setCacheWriteDataSinkFactory(null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DownloadModule {
|
||||
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache {
|
||||
val downloadDir = File(context.getExternalFilesDir(null), "downloads")
|
||||
return SimpleCache(
|
||||
downloadDir,
|
||||
DownloadModuleFactories.createDownloadCacheEvictor(),
|
||||
StandaloneDatabaseProvider(context)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadNotificationHelper(@ApplicationContext context: Context): DownloadNotificationHelper {
|
||||
return DownloadNotificationHelper(context, DOWNLOAD_CHANNEL_ID)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDownloadManager(
|
||||
@ApplicationContext context: Context,
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DownloadManager {
|
||||
return DownloadManager(
|
||||
context,
|
||||
StandaloneDatabaseProvider(context),
|
||||
cache,
|
||||
okHttpDataSourceFactory,
|
||||
Executors.newFixedThreadPool(2)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackDataSourceFactory(
|
||||
cache: SimpleCache,
|
||||
okHttpDataSourceFactory: OkHttpDataSource.Factory
|
||||
): DataSource.Factory {
|
||||
return DownloadModuleFactories
|
||||
.newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory)
|
||||
.setCache(cache)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
sealed class DownloadState {
|
||||
data object NotDownloaded : DownloadState()
|
||||
data class Downloading(val progressPercent: Float) : DownloadState()
|
||||
data object Downloaded : DownloadState()
|
||||
data object Failed : DownloadState()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.UUID
|
||||
|
||||
interface MediaDownloadController {
|
||||
fun observeActiveDownloads(): Flow<Map<String, Float>>
|
||||
fun observeDownloadState(contentId: String): StateFlow<DownloadState>
|
||||
suspend fun downloadMovie(movieId: UUID)
|
||||
suspend fun cancelDownload(movieId: UUID)
|
||||
suspend fun downloadEpisode(episodeId: UUID)
|
||||
suspend fun downloadEpisodes(episodeIds: List<UUID>)
|
||||
suspend fun cancelEpisodeDownload(episodeId: UUID)
|
||||
suspend fun enableSmartDownload(seriesId: UUID)
|
||||
suspend fun syncSmartDownloads()
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import hu.bbara.purefin.core.data.DownloadMediaSourceResolver
|
||||
import hu.bbara.purefin.core.data.OfflineCatalogStore
|
||||
import hu.bbara.purefin.core.data.SmartDownloadStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Singleton
|
||||
class MediaDownloadManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val downloadMediaSourceResolver: DownloadMediaSourceResolver,
|
||||
private val offlineCatalogStore: OfflineCatalogStore,
|
||||
private val smartDownloadStore: SmartDownloadStore,
|
||||
) : MediaDownloadController {
|
||||
|
||||
private val stateFlows = ConcurrentHashMap<String, MutableStateFlow<DownloadState>>()
|
||||
|
||||
init {
|
||||
downloadManager.resumeDownloads()
|
||||
|
||||
downloadManager.addListener(object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(
|
||||
manager: DownloadManager,
|
||||
download: Download,
|
||||
finalException: Exception?
|
||||
) {
|
||||
val contentId = download.request.id
|
||||
val state = download.toDownloadState()
|
||||
Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)")
|
||||
if (finalException != null) {
|
||||
Log.e(TAG, "Download exception for $contentId", finalException)
|
||||
}
|
||||
getOrCreateStateFlow(contentId).value = state
|
||||
}
|
||||
|
||||
override fun onDownloadRemoved(manager: DownloadManager, download: Download) {
|
||||
val contentId = download.request.id
|
||||
Log.d(TAG, "Download removed: $contentId")
|
||||
getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the download index every 500 ms and emits a map of contentId → progress (0–100)
|
||||
* for every download that is currently queued or in progress.
|
||||
* Uses the download index directly so it always reflects the true download state,
|
||||
* regardless of listener callback timing.
|
||||
*/
|
||||
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flow {
|
||||
while (true) {
|
||||
try {
|
||||
val result = buildMap<String, Float> {
|
||||
val cursor = downloadManager.downloadIndex.getDownloads(
|
||||
Download.STATE_QUEUED,
|
||||
Download.STATE_DOWNLOADING,
|
||||
Download.STATE_RESTARTING
|
||||
)
|
||||
cursor.use {
|
||||
while (it.moveToNext()) {
|
||||
val d = it.download
|
||||
put(d.request.id, d.percentDownloaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(result)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to poll active downloads", e)
|
||||
emit(emptyMap())
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO).distinctUntilChanged()
|
||||
|
||||
override fun observeDownloadState(contentId: String): StateFlow<DownloadState> {
|
||||
val flow = getOrCreateStateFlow(contentId)
|
||||
// Initialize from current download index
|
||||
val download = downloadManager.downloadIndex.getDownload(contentId)
|
||||
flow.value = download?.toDownloadState() ?: DownloadState.NotDownloaded
|
||||
return flow
|
||||
}
|
||||
|
||||
fun isDownloaded(contentId: String): Boolean {
|
||||
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED
|
||||
}
|
||||
|
||||
override suspend fun downloadMovie(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run {
|
||||
Log.e(TAG, "No downloadable movie source for $movieId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveMovies(listOf(source.movie))
|
||||
|
||||
Log.d(TAG, "Starting download for '${source.movie.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = movieId,
|
||||
playbackUrl = source.playbackUrl,
|
||||
title = source.movie.title,
|
||||
customCacheKey = source.customCacheKey,
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for $movieId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for $movieId", e)
|
||||
getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelDownload(movieId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, movieId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteMovie(movieId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove movie from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadEpisode(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run {
|
||||
Log.e(TAG, "No downloadable episode source for $episodeId")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeriesBasic(source.series.id) == null) {
|
||||
offlineCatalogStore.saveSeries(listOf(source.series))
|
||||
}
|
||||
|
||||
if (offlineCatalogStore.getSeason(source.season.id) == null) {
|
||||
offlineCatalogStore.saveSeason(source.season)
|
||||
}
|
||||
|
||||
offlineCatalogStore.saveEpisode(source.episode)
|
||||
|
||||
Log.d(TAG, "Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}")
|
||||
val request = buildDownloadRequest(
|
||||
mediaId = episodeId,
|
||||
playbackUrl = source.playbackUrl,
|
||||
title = source.episode.title,
|
||||
customCacheKey = source.customCacheKey,
|
||||
)
|
||||
PurefinDownloadService.sendAddDownload(context, request)
|
||||
Log.d(TAG, "Download request sent for episode $episodeId")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start download for episode $episodeId", e)
|
||||
getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadEpisodes(episodeIds: List<UUID>) {
|
||||
coroutineScope {
|
||||
for (episodeId in episodeIds) {
|
||||
launch { downloadEpisode(episodeId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancelEpisodeDownload(episodeId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
PurefinDownloadService.sendRemoveDownload(context, episodeId.toString())
|
||||
try {
|
||||
offlineCatalogStore.deleteEpisodeAndCleanup(episodeId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove episode from offline DB", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Smart Download ──────────────────────────────────────────────────
|
||||
|
||||
override suspend fun enableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.enable(seriesId)
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
}
|
||||
|
||||
suspend fun disableSmartDownload(seriesId: UUID) {
|
||||
smartDownloadStore.disable(seriesId)
|
||||
}
|
||||
|
||||
fun isSmartDownloadEnabled(seriesId: UUID): Flow<Boolean> = smartDownloadStore.observe(seriesId)
|
||||
|
||||
override suspend fun syncSmartDownloads() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val enabledSeriesIds = smartDownloadStore.getEnabledSeriesIds()
|
||||
for (seriesId in enabledSeriesIds) {
|
||||
try {
|
||||
syncSmartDownloadsForSeries(seriesId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Smart download sync failed for series $seriesId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncSmartDownloadsForSeries(seriesId: UUID) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// 1. Get currently downloaded episodes for this series
|
||||
val downloadedEpisodes = offlineCatalogStore.getEpisodesBySeries(seriesId)
|
||||
|
||||
// 2. Check watched status from server and delete watched downloads
|
||||
val unwatchedDownloaded = mutableListOf<UUID>()
|
||||
for (episode in downloadedEpisodes) {
|
||||
if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) {
|
||||
Log.d(TAG, "Smart download: removing watched episode ${episode.title}")
|
||||
cancelEpisodeDownload(episode.id)
|
||||
} else {
|
||||
unwatchedDownloaded.add(episode.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Find unwatched episodes not already downloaded
|
||||
val needed = SMART_DOWNLOAD_COUNT - unwatchedDownloaded.size
|
||||
if (needed <= 0) return@withContext
|
||||
|
||||
val toDownload = downloadMediaSourceResolver.getUnwatchedEpisodeIds(
|
||||
seriesId = seriesId,
|
||||
excludedEpisodeIds = unwatchedDownloaded.toSet(),
|
||||
limit = needed,
|
||||
)
|
||||
|
||||
if (toDownload.isNotEmpty()) {
|
||||
Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId")
|
||||
downloadEpisodes(toDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateStateFlow(contentId: String): MutableStateFlow<DownloadState> {
|
||||
return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) }
|
||||
}
|
||||
|
||||
private fun buildDownloadRequest(
|
||||
mediaId: UUID,
|
||||
playbackUrl: String,
|
||||
title: String,
|
||||
customCacheKey: String?,
|
||||
): DownloadRequest {
|
||||
val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri())
|
||||
.setData(title.toByteArray(Charsets.UTF_8))
|
||||
|
||||
customCacheKey?.let(builder::setCustomCacheKey)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun Download.toDownloadState(): DownloadState = when (state) {
|
||||
Download.STATE_COMPLETED -> DownloadState.Downloaded
|
||||
Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded)
|
||||
Download.STATE_QUEUED, Download.STATE_RESTARTING -> DownloadState.Downloading(0f)
|
||||
Download.STATE_FAILED -> DownloadState.Failed
|
||||
Download.STATE_REMOVING -> DownloadState.NotDownloaded
|
||||
Download.STATE_STOPPED -> DownloadState.NotDownloaded
|
||||
else -> DownloadState.NotDownloaded
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MediaDownloadManager"
|
||||
private const val SMART_DOWNLOAD_COUNT = 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.exoplayer.scheduler.Scheduler
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PurefinDownloadService : DownloadService(
|
||||
FOREGROUND_NOTIFICATION_ID,
|
||||
DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL,
|
||||
DOWNLOAD_CHANNEL_ID,
|
||||
R.string.download_channel_name,
|
||||
R.string.download_channel_description
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DownloadServiceEntryPoint {
|
||||
fun downloadManager(): DownloadManager
|
||||
fun downloadNotificationHelper(): DownloadNotificationHelper
|
||||
}
|
||||
|
||||
private val entryPoint: DownloadServiceEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication(applicationContext, DownloadServiceEntryPoint::class.java)
|
||||
}
|
||||
|
||||
private var lastBytesDownloaded: Long = 0L
|
||||
private var lastUpdateTimeMs: Long = 0L
|
||||
|
||||
override fun getDownloadManager(): DownloadManager = entryPoint.downloadManager()
|
||||
|
||||
override fun getForegroundNotification(
|
||||
downloads: MutableList<Download>,
|
||||
notMetRequirements: Int
|
||||
): Notification {
|
||||
val activeDownloads = downloads.filter { it.state == Download.STATE_DOWNLOADING }
|
||||
|
||||
if (activeDownloads.isEmpty()) {
|
||||
return entryPoint.downloadNotificationHelper().buildProgressNotification(
|
||||
this,
|
||||
android.R.drawable.stat_sys_download,
|
||||
null,
|
||||
null,
|
||||
downloads,
|
||||
notMetRequirements
|
||||
)
|
||||
}
|
||||
|
||||
val totalBytes = activeDownloads.sumOf { it.bytesDownloaded }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val speedText = if (lastUpdateTimeMs > 0L) {
|
||||
val elapsed = (now - lastUpdateTimeMs).coerceAtLeast(1)
|
||||
val bytesPerSec = (totalBytes - lastBytesDownloaded) * 1000L / elapsed
|
||||
formatSpeed(bytesPerSec)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
lastBytesDownloaded = totalBytes
|
||||
lastUpdateTimeMs = now
|
||||
|
||||
val percent = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].percentDownloaded
|
||||
} else {
|
||||
activeDownloads.map { it.percentDownloaded }.average().toFloat()
|
||||
}
|
||||
|
||||
val title = if (activeDownloads.size == 1) {
|
||||
activeDownloads[0].request.data
|
||||
?.toString(Charsets.UTF_8)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Downloading"
|
||||
} else {
|
||||
"Downloading ${activeDownloads.size} files"
|
||||
}
|
||||
|
||||
val contentText = buildString {
|
||||
append("${percent.toInt()}%")
|
||||
if (speedText.isNotEmpty()) {
|
||||
append(" · $speedText")
|
||||
}
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, DOWNLOAD_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle(title)
|
||||
.setContentText(contentText)
|
||||
.setProgress(100, percent.toInt(), false)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getScheduler(): Scheduler? = null
|
||||
|
||||
private fun formatSpeed(bytesPerSec: Long): String {
|
||||
if (bytesPerSec <= 0) return ""
|
||||
return when {
|
||||
bytesPerSec >= 1_000_000 -> String.format("%.1f MB/s", bytesPerSec / 1_000_000.0)
|
||||
bytesPerSec >= 1_000 -> String.format("%.0f KB/s", bytesPerSec / 1_000.0)
|
||||
else -> "$bytesPerSec B/s"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOREGROUND_NOTIFICATION_ID = 1
|
||||
private const val DOWNLOAD_CHANNEL_ID = "purefin_downloads"
|
||||
|
||||
fun sendAddDownload(context: Context, request: DownloadRequest) {
|
||||
sendAddDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
request,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
fun sendRemoveDownload(context: Context, contentId: String) {
|
||||
sendRemoveDownload(
|
||||
context,
|
||||
PurefinDownloadService::class.java,
|
||||
contentId,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class EpisodeDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val seasonId: UUID,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val seriesId: UUID,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class LibraryDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class MovieDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
sealed interface NavigationCommand {
|
||||
data class Navigate(val route: Route) : NavigationCommand
|
||||
data class ReplaceAll(val route: Route) : NavigationCommand
|
||||
data object Pop : NavigationCommand
|
||||
}
|
||||
|
||||
interface NavigationManager {
|
||||
val commands: SharedFlow<NavigationCommand>
|
||||
fun navigate(route: Route)
|
||||
fun replaceAll(route: Route)
|
||||
fun pop()
|
||||
}
|
||||
|
||||
class DefaultNavigationManager : NavigationManager {
|
||||
private val commandsFlow = MutableSharedFlow<NavigationCommand>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val commands: SharedFlow<NavigationCommand> = commandsFlow.asSharedFlow()
|
||||
|
||||
override fun navigate(route: Route) {
|
||||
commandsFlow.tryEmit(NavigationCommand.Navigate(route))
|
||||
}
|
||||
|
||||
override fun replaceAll(route: Route) {
|
||||
commandsFlow.tryEmit(NavigationCommand.ReplaceAll(route))
|
||||
}
|
||||
|
||||
override fun pop() {
|
||||
commandsFlow.tryEmit(NavigationCommand.Pop)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NavigationManagerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNavigationManager(): NavigationManager = DefaultNavigationManager()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface Route : NavKey {
|
||||
@Serializable
|
||||
data object Home : Route
|
||||
|
||||
@Serializable
|
||||
data class MovieRoute(val item: MovieDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class SeriesRoute(val item: SeriesDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class EpisodeRoute(val item: EpisodeDto) : Route
|
||||
|
||||
@Serializable
|
||||
data class LibraryRoute(val library: LibraryDto) : Route
|
||||
|
||||
@Serializable
|
||||
data object LoginRoute : Route
|
||||
|
||||
@Serializable
|
||||
data class PlayerRoute(val mediaId: String) : Route
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
import hu.bbara.purefin.core.navigation.UuidSerializer
|
||||
|
||||
@Serializable
|
||||
data class SeriesDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: UUID,
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.PlayerMessage
|
||||
import hu.bbara.purefin.core.model.MediaSegment
|
||||
import hu.bbara.purefin.core.model.SegmentType
|
||||
import hu.bbara.purefin.core.player.model.SegmentStatus
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class MediaSegmentManager(private val player: ExoPlayer) {
|
||||
|
||||
private var listener: MediaSegmentListener? = null
|
||||
|
||||
interface MediaSegmentListener {
|
||||
fun onEvent(segmentType: SegmentType, status: SegmentStatus)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun registerListener(listener: MediaSegmentListener) {
|
||||
this.listener?.let {
|
||||
Log.w("PlayerMessageManager", "Listener was already register")
|
||||
return
|
||||
}
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
fun addMediaSegments(mediaSegments: List<MediaSegment>) {
|
||||
val messageTarget = PlayerMessage.Target { messageType, payload ->
|
||||
SegmentType.fromValue(messageType)?.let { segmentType ->
|
||||
notifyListener(segmentType, payload as SegmentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
mediaSegments.forEach { mediaSegment ->
|
||||
installMediaSegment(messageTarget, mediaSegment)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun notifyListener(segmentType: SegmentType, status: SegmentStatus) {
|
||||
if (listener == null) {
|
||||
Log.w("PlayerMessageManager", "Listener was not register therefore it cannot notify")
|
||||
}
|
||||
listener?.onEvent(segmentType, status)
|
||||
|
||||
}
|
||||
|
||||
private fun installMediaSegment(messageTarget: PlayerMessage.Target, mediaSegment: MediaSegment) {
|
||||
player.createMessage(messageTarget)
|
||||
.setType(mediaSegment.type.value)
|
||||
.setPosition(mediaSegment.startMs)
|
||||
.setPayload(SegmentStatus.START)
|
||||
.setLooper(Looper.getMainLooper())
|
||||
.setDeleteAfterDelivery(false)
|
||||
.send()
|
||||
|
||||
player.createMessage(messageTarget)
|
||||
.setType(mediaSegment.type.value)
|
||||
.setPayload(SegmentStatus.END)
|
||||
.setPosition(mediaSegment.endMs)
|
||||
.setLooper(Looper.getMainLooper())
|
||||
.setDeleteAfterDelivery(false)
|
||||
.send()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.model.MediaSegment
|
||||
import hu.bbara.purefin.core.player.model.MediaContext
|
||||
import hu.bbara.purefin.core.player.model.MetadataState
|
||||
import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot
|
||||
import hu.bbara.purefin.core.player.model.PlaybackStateSnapshot
|
||||
import hu.bbara.purefin.core.player.model.QueueItemUi
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
import hu.bbara.purefin.core.player.preference.AudioTrackProperties
|
||||
import hu.bbara.purefin.core.player.preference.SubtitleTrackProperties
|
||||
import hu.bbara.purefin.core.player.preference.TrackMatcher
|
||||
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Encapsulates the Media3 [Player] wiring and exposes reactive updates for the UI layer.
|
||||
*/
|
||||
@ViewModelScoped
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlayerManager @Inject constructor(
|
||||
val player: Player,
|
||||
private val trackMapper: TrackMapper,
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val trackMatcher: TrackMatcher
|
||||
) {
|
||||
companion object {
|
||||
private const val SEEK_SETTLE_TOLERANCE_MS = 750L
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val mediaSegmentManager = MediaSegmentManager(player as ExoPlayer)
|
||||
|
||||
private var currentMediaContext: MediaContext? = null
|
||||
private var pendingSeekPositionMs: Long? = null
|
||||
|
||||
private val _playbackState = MutableStateFlow(PlaybackStateSnapshot())
|
||||
val playbackState: StateFlow<PlaybackStateSnapshot> = _playbackState.asStateFlow()
|
||||
|
||||
private val _progress = MutableStateFlow(PlaybackProgressSnapshot())
|
||||
val progress: StateFlow<PlaybackProgressSnapshot> = _progress.asStateFlow()
|
||||
|
||||
private val _metadata = MutableStateFlow(MetadataState())
|
||||
val metadata: StateFlow<MetadataState> = _metadata.asStateFlow()
|
||||
|
||||
private val _tracks = MutableStateFlow(TrackSelectionState())
|
||||
val tracks: StateFlow<TrackSelectionState> = _tracks.asStateFlow()
|
||||
|
||||
private val _queue = MutableStateFlow<List<QueueItemUi>>(emptyList())
|
||||
val queue: StateFlow<List<QueueItemUi>> = _queue.asStateFlow()
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
syncPendingSeek(newPosition.positionMs)
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_playbackState.update {
|
||||
it.copy(error = mapPlayerError(error))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
clearPendingSeek()
|
||||
currentMediaContext?.let {
|
||||
installMediaSegments(it.mediaSegments)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
updateFromPlayer(player)
|
||||
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||
scope.launch {
|
||||
applyTrackPreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
updateFromPlayer(player)
|
||||
startProgressLoop()
|
||||
}
|
||||
|
||||
fun play(mediaItem: MediaItem, mediaContext: MediaContext? = null) {
|
||||
currentMediaContext = mediaContext
|
||||
clearPendingSeek()
|
||||
player.setMediaItem(mediaItem)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
fun addToQueue(mediaItem: MediaItem) {
|
||||
player.addMediaItem(mediaItem)
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
if (player.isPlaying) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
|
||||
fun resumePlayback() {
|
||||
if (!player.isPlaying) {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
val target = clampSeekPosition(positionMs)
|
||||
pendingSeekPositionMs = target
|
||||
_progress.update { progress ->
|
||||
progress.copy(
|
||||
durationMs = resolveDurationMs(),
|
||||
positionMs = target,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
}
|
||||
player.seekTo(target)
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) {
|
||||
val basePosition = pendingSeekPositionMs ?: player.currentPosition
|
||||
val target = clampSeekPosition(basePosition + deltaMs)
|
||||
seekTo(target)
|
||||
}
|
||||
|
||||
fun seekToLiveEdge() {
|
||||
clearPendingSeek()
|
||||
if (player.isCurrentMediaItemLive) {
|
||||
player.seekToDefaultPosition()
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
fun next() {
|
||||
clearPendingSeek()
|
||||
if (player.hasNextMediaItem()) {
|
||||
player.seekToNextMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
clearPendingSeek()
|
||||
if (player.hasPreviousMediaItem()) {
|
||||
player.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTrack(option: TrackOption) {
|
||||
val builder = player.trackSelectionParameters.buildUpon()
|
||||
when (option.type) {
|
||||
TrackType.TEXT -> {
|
||||
if (option.isOff) {
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
} else {
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrackType.AUDIO -> {
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
|
||||
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
|
||||
)
|
||||
}
|
||||
|
||||
TrackType.VIDEO -> {
|
||||
builder.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
|
||||
val group = player.currentTracks.groups.getOrNull(option.groupIndex) ?: return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(group.mediaTrackGroup, listOf(option.trackIndex))
|
||||
)
|
||||
}
|
||||
}
|
||||
player.trackSelectionParameters = builder.build()
|
||||
|
||||
// Save track preference if media context is available
|
||||
currentMediaContext?.let { context ->
|
||||
scope.launch {
|
||||
saveTrackPreference(option, context.mediaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun installMediaSegments(mediaSegments: List<MediaSegment>) {
|
||||
mediaSegmentManager.addMediaSegments(
|
||||
mediaSegments = mediaSegments
|
||||
)
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
clearPendingSeek()
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
|
||||
fun playQueueItem(id: String) {
|
||||
val items = _queue.value
|
||||
val targetIndex = items.indexOfFirst { it.id == id }
|
||||
if (targetIndex >= 0) {
|
||||
clearPendingSeek()
|
||||
player.seekToDefaultPosition(targetIndex)
|
||||
player.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_playbackState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
private fun applyTrackPreferences() {
|
||||
val context = currentMediaContext ?: return
|
||||
val preferences = context.preferences
|
||||
|
||||
val currentTrackState = _tracks.value
|
||||
|
||||
// Apply audio preference
|
||||
preferences.audioPreference?.let { audioPreference ->
|
||||
val matchedAudio = trackMatcher.findBestAudioMatch(
|
||||
currentTrackState.audioTracks,
|
||||
audioPreference
|
||||
)
|
||||
matchedAudio?.let { selectTrack(it) }
|
||||
}
|
||||
|
||||
// Apply subtitle preference
|
||||
preferences.subtitlePreference?.let { subtitlePreference ->
|
||||
val matchedSubtitle = trackMatcher.findBestSubtitleMatch(
|
||||
currentTrackState.textTracks,
|
||||
subtitlePreference
|
||||
)
|
||||
matchedSubtitle?.let { selectTrack(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveTrackPreference(option: TrackOption, preferenceKey: String) {
|
||||
when (option.type) {
|
||||
TrackType.AUDIO -> {
|
||||
val properties = AudioTrackProperties(
|
||||
language = option.language,
|
||||
channelCount = option.channelCount,
|
||||
label = option.label
|
||||
)
|
||||
trackPreferencesRepository.saveAudioPreference(preferenceKey, properties)
|
||||
}
|
||||
|
||||
TrackType.TEXT -> {
|
||||
val properties = SubtitleTrackProperties(
|
||||
language = option.language,
|
||||
forced = option.forced,
|
||||
label = option.label,
|
||||
isOff = option.isOff
|
||||
)
|
||||
trackPreferencesRepository.saveSubtitlePreference(preferenceKey, properties)
|
||||
}
|
||||
|
||||
TrackType.VIDEO -> {
|
||||
// Video preferences not implemented in this feature
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
scope.cancel()
|
||||
player.removeListener(listener)
|
||||
player.release()
|
||||
}
|
||||
|
||||
private fun startProgressLoop() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val duration = player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
|
||||
val actualPosition = player.currentPosition
|
||||
syncPendingSeek(actualPosition)
|
||||
val position = pendingSeekPositionMs ?: actualPosition
|
||||
val buffered = player.bufferedPosition
|
||||
_progress.value = PlaybackProgressSnapshot(
|
||||
durationMs = duration,
|
||||
positionMs = position,
|
||||
bufferedMs = buffered,
|
||||
isLive = player.isCurrentMediaItemLive
|
||||
)
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFromPlayer(player: Player) {
|
||||
val currentMediaItem = player.currentMediaItem
|
||||
val playbackState = player.playbackState
|
||||
val playbackReportContext = currentMediaItem?.localConfiguration?.tag as? PlaybackReportContext
|
||||
val currentMetadata = player.mediaMetadata
|
||||
val playerError = player.playerError
|
||||
|
||||
_playbackState.value = PlaybackStateSnapshot(
|
||||
isPlaying = player.isPlaying,
|
||||
isBuffering = playbackState == Player.STATE_BUFFERING,
|
||||
isEnded = playbackState == Player.STATE_ENDED,
|
||||
error = mapPlayerError(playerError)
|
||||
)
|
||||
_metadata.value = MetadataState(
|
||||
mediaId = currentMediaItem?.mediaId,
|
||||
title = currentMetadata.title?.toString(),
|
||||
subtitle = currentMetadata.subtitle?.toString(),
|
||||
playbackReportContext = playbackReportContext,
|
||||
)
|
||||
_tracks.value = trackMapper.map(player.currentTracks)
|
||||
_queue.value = buildQueueSnapshot(player)
|
||||
}
|
||||
|
||||
private fun buildQueueSnapshot(player: Player): List<QueueItemUi> {
|
||||
val items = mutableListOf<QueueItemUi>()
|
||||
for (i in 0 until player.mediaItemCount) {
|
||||
val mediaItem = player.getMediaItemAt(i)
|
||||
items.add(
|
||||
QueueItemUi(
|
||||
id = mediaItem.mediaId.ifEmpty { i.toString() },
|
||||
title = mediaItem.mediaMetadata.title?.toString() ?: "Item ${i + 1}",
|
||||
subtitle = mediaItem.mediaMetadata.subtitle?.toString(),
|
||||
artworkUrl = mediaItem.mediaMetadata.artworkUri?.toString(),
|
||||
isCurrent = i == player.currentMediaItemIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private fun clampSeekPosition(positionMs: Long): Long {
|
||||
val duration = resolveDurationMs()
|
||||
return if (duration > 0) {
|
||||
positionMs.coerceIn(0L, duration)
|
||||
} else {
|
||||
positionMs.coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDurationMs(): Long {
|
||||
return player.duration.takeIf { it > 0 } ?: _progress.value.durationMs
|
||||
}
|
||||
|
||||
private fun clearPendingSeek() {
|
||||
pendingSeekPositionMs = null
|
||||
}
|
||||
|
||||
private fun mapPlayerError(error: PlaybackException?): String? {
|
||||
return error?.errorCodeName ?: error?.localizedMessage ?: error?.message
|
||||
}
|
||||
|
||||
private fun syncPendingSeek(positionMs: Long) {
|
||||
val pendingPosition = pendingSeekPositionMs ?: return
|
||||
if (abs(positionMs - pendingPosition) <= SEEK_SETTLE_TOLERANCE_MS) {
|
||||
clearPendingSeek()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
import hu.bbara.purefin.core.data.MediaProgressWriter
|
||||
import hu.bbara.purefin.core.data.PlaybackProgressReporter
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.player.model.MetadataState
|
||||
import hu.bbara.purefin.core.player.model.PlaybackProgressSnapshot
|
||||
import hu.bbara.purefin.core.player.model.PlaybackStateSnapshot
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ViewModelScoped
|
||||
class ProgressManager @Inject constructor(
|
||||
private val playbackProgressReporter: PlaybackProgressReporter,
|
||||
private val mediaProgressWriter: MediaProgressWriter,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private var progressJob: Job? = null
|
||||
private var activeItemId: UUID? = null
|
||||
private var activePlaybackReportContext: PlaybackReportContext? = null
|
||||
private var lastPositionMs: Long = 0L
|
||||
private var lastDurationMs: Long = 0L
|
||||
private var isPaused: Boolean = false
|
||||
|
||||
fun bind(
|
||||
playbackState: StateFlow<PlaybackStateSnapshot>,
|
||||
progress: StateFlow<PlaybackProgressSnapshot>,
|
||||
metadata: StateFlow<MetadataState>
|
||||
) {
|
||||
scope.launch {
|
||||
combine(playbackState, progress, metadata) { state, prog, meta ->
|
||||
Triple(state, prog, meta)
|
||||
}.collect { (state, prog, meta) ->
|
||||
lastPositionMs = prog.positionMs
|
||||
lastDurationMs = prog.durationMs
|
||||
isPaused = !state.isPlaying
|
||||
val mediaId = meta.mediaId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||
val nextPlaybackReportContext = meta.playbackReportContext
|
||||
|
||||
if (activeItemId != null && (mediaId != activeItemId || state.isEnded)) {
|
||||
stopSession()
|
||||
}
|
||||
|
||||
if (activeItemId == null && mediaId != null && !state.isEnded) {
|
||||
startSession(mediaId, prog.positionMs, nextPlaybackReportContext)
|
||||
} else if (activeItemId == mediaId) {
|
||||
activePlaybackReportContext = nextPlaybackReportContext
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSession(itemId: UUID, positionMs: Long, reportContext: PlaybackReportContext?) {
|
||||
activeItemId = itemId
|
||||
activePlaybackReportContext = reportContext
|
||||
report(itemId, positionMs, reportContext = reportContext, isStart = true)
|
||||
progressJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(5000)
|
||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isPaused = isPaused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSession() {
|
||||
progressJob?.cancel()
|
||||
activeItemId?.let { itemId ->
|
||||
report(itemId, lastPositionMs, reportContext = activePlaybackReportContext, isStop = true)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
mediaProgressWriter.updateWatchProgress(itemId, lastPositionMs, lastDurationMs)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ProgressManager", "Local cache update failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
activeItemId = null
|
||||
activePlaybackReportContext = null
|
||||
}
|
||||
|
||||
private fun report(
|
||||
itemId: UUID,
|
||||
positionMs: Long,
|
||||
reportContext: PlaybackReportContext?,
|
||||
isPaused: Boolean = false,
|
||||
isStart: Boolean = false,
|
||||
isStop: Boolean = false
|
||||
) {
|
||||
val ticks = positionMs * 10_000
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (reportContext == null) {
|
||||
return@launch
|
||||
}
|
||||
when {
|
||||
isStart -> playbackProgressReporter.reportPlaybackStart(itemId, ticks, reportContext)
|
||||
isStop -> playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||
else -> playbackProgressReporter.reportPlaybackProgress(itemId, ticks, isPaused, reportContext)
|
||||
}
|
||||
Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused")
|
||||
} catch (e: Exception) {
|
||||
Log.e("ProgressManager", "Report failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
progressJob?.cancel()
|
||||
activeItemId?.let { itemId ->
|
||||
val ticks = lastPositionMs * 10_000
|
||||
val posMs = lastPositionMs
|
||||
val durMs = lastDurationMs
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
activePlaybackReportContext?.let { reportContext ->
|
||||
playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext)
|
||||
}
|
||||
mediaProgressWriter.updateWatchProgress(itemId, posMs, durMs)
|
||||
Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms")
|
||||
} catch (e: Exception) {
|
||||
Log.e("ProgressManager", "Report failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package hu.bbara.purefin.core.player.manager
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TrackSelectionState(
|
||||
val audioTracks: List<TrackOption> = emptyList(),
|
||||
val textTracks: List<TrackOption> = emptyList(),
|
||||
val videoTracks: List<TrackOption> = emptyList(),
|
||||
val selectedAudioTrackId: String? = null,
|
||||
val selectedTextTrackId: String? = null,
|
||||
val selectedVideoTrackId: String? = null
|
||||
)
|
||||
|
||||
class TrackMapper @Inject constructor() {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun map(tracks: Tracks): TrackSelectionState {
|
||||
val audio = mutableListOf<TrackOption>()
|
||||
val text = mutableListOf<TrackOption>()
|
||||
val video = mutableListOf<TrackOption>()
|
||||
var selectedAudio: String? = null
|
||||
var selectedText: String? = null
|
||||
var selectedVideo: String? = null
|
||||
|
||||
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||
when (group.type) {
|
||||
C.TRACK_TYPE_AUDIO -> {
|
||||
repeat(group.length) { trackIndex ->
|
||||
val format = group.getTrackFormat(trackIndex)
|
||||
val id = "a_${groupIndex}_${trackIndex}"
|
||||
val label = format.label
|
||||
?: format.language
|
||||
?: "${format.channelCount}ch"
|
||||
?: "Audio ${trackIndex}"
|
||||
val option = TrackOption(
|
||||
id = id,
|
||||
label = label,
|
||||
language = format.language,
|
||||
bitrate = format.bitrate,
|
||||
channelCount = format.channelCount,
|
||||
height = null,
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
type = TrackType.AUDIO,
|
||||
isOff = false
|
||||
)
|
||||
audio.add(option)
|
||||
if (group.isTrackSelected(trackIndex)) selectedAudio = id
|
||||
}
|
||||
}
|
||||
|
||||
C.TRACK_TYPE_TEXT -> {
|
||||
repeat(group.length) { trackIndex ->
|
||||
val format = group.getTrackFormat(trackIndex)
|
||||
val id = "t_${groupIndex}_${trackIndex}"
|
||||
val label = format.label
|
||||
?: format.language
|
||||
?: "Subtitle ${trackIndex}"
|
||||
val isForced = (format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0
|
||||
val option = TrackOption(
|
||||
id = id,
|
||||
label = label,
|
||||
language = format.language,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
type = TrackType.TEXT,
|
||||
isOff = false,
|
||||
forced = isForced
|
||||
)
|
||||
text.add(option)
|
||||
if (group.isTrackSelected(trackIndex)) selectedText = id
|
||||
}
|
||||
}
|
||||
|
||||
C.TRACK_TYPE_VIDEO -> {
|
||||
repeat(group.length) { trackIndex ->
|
||||
val format = group.getTrackFormat(trackIndex)
|
||||
val id = "v_${groupIndex}_${trackIndex}"
|
||||
val res = if (format.height != Format.NO_VALUE) "${format.height}p" else null
|
||||
val label = res ?: format.label ?: "Video ${trackIndex}"
|
||||
val option = TrackOption(
|
||||
id = id,
|
||||
label = label,
|
||||
language = null,
|
||||
bitrate = format.bitrate,
|
||||
channelCount = null,
|
||||
height = format.height.takeIf { it > 0 },
|
||||
groupIndex = groupIndex,
|
||||
trackIndex = trackIndex,
|
||||
type = TrackType.VIDEO,
|
||||
isOff = false
|
||||
)
|
||||
video.add(option)
|
||||
if (group.isTrackSelected(trackIndex)) selectedVideo = id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isNotEmpty()) {
|
||||
text.add(
|
||||
0,
|
||||
TrackOption(
|
||||
id = "text_off",
|
||||
label = "Off",
|
||||
language = null,
|
||||
bitrate = null,
|
||||
channelCount = null,
|
||||
height = null,
|
||||
groupIndex = -1,
|
||||
trackIndex = -1,
|
||||
type = TrackType.TEXT,
|
||||
isOff = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return TrackSelectionState(
|
||||
audioTracks = audio,
|
||||
textTracks = text,
|
||||
videoTracks = video,
|
||||
selectedAudioTrackId = selectedAudio,
|
||||
selectedTextTrackId = selectedText ?: text.firstOrNull { option -> option.isOff }?.id,
|
||||
selectedVideoTrackId = selectedVideo
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import hu.bbara.purefin.core.model.MediaSegment
|
||||
import hu.bbara.purefin.core.player.preference.MediaTrackPreferences
|
||||
|
||||
data class MediaContext(
|
||||
val mediaId: String,
|
||||
val preferences: MediaTrackPreferences,
|
||||
val mediaSegments: List<MediaSegment>
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
|
||||
data class MetadataState(
|
||||
val mediaId: String? = null,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
val playbackReportContext: PlaybackReportContext? = null,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
data class PlaybackProgressSnapshot(
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val isLive: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
data class PlaybackStateSnapshot(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
data class PlayerUiState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val isEnded: Boolean = false,
|
||||
val isLive: Boolean = false,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val bufferedMs: Long = 0L,
|
||||
val error: String? = null,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val chapters: List<TimedMarker> = emptyList(),
|
||||
val ads: List<TimedMarker> = emptyList(),
|
||||
val queue: List<QueueItemUi> = emptyList(),
|
||||
val audioTracks: List<TrackOption> = emptyList(),
|
||||
val textTracks: List<TrackOption> = emptyList(),
|
||||
val qualityTracks: List<TrackOption> = emptyList(),
|
||||
val selectedAudioTrackId: String? = null,
|
||||
val selectedTextTrackId: String? = null,
|
||||
val selectedQualityTrackId: String? = null,
|
||||
)
|
||||
|
||||
data class TrackOption(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val language: String?,
|
||||
val bitrate: Int?,
|
||||
val channelCount: Int?,
|
||||
val height: Int?,
|
||||
val groupIndex: Int,
|
||||
val trackIndex: Int,
|
||||
val type: TrackType,
|
||||
val isOff: Boolean,
|
||||
val forced: Boolean = false
|
||||
)
|
||||
|
||||
enum class TrackType { AUDIO, TEXT, VIDEO }
|
||||
|
||||
data class TimedMarker(
|
||||
val positionMs: Long,
|
||||
val type: MarkerType,
|
||||
val label: String? = null
|
||||
)
|
||||
|
||||
enum class MarkerType { CHAPTER, AD }
|
||||
|
||||
data class QueueItemUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val artworkUrl: String?,
|
||||
val isCurrent: Boolean
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package hu.bbara.purefin.core.player.model
|
||||
|
||||
enum class SegmentStatus {
|
||||
START,
|
||||
END
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package hu.bbara.purefin.core.player.module
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Singleton
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlaybackNetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory {
|
||||
return OkHttpDataSource.Factory(okHttpClient)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package hu.bbara.purefin.core.player.module
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.SeekParameters
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ViewModelComponent
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
object VideoPlayerModule {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideVideoPlayer(
|
||||
application: Application,
|
||||
playbackDataSourceFactory: DataSource.Factory
|
||||
): Player {
|
||||
val trackSelector = DefaultTrackSelector(application)
|
||||
val audioAttributes =
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
trackSelector.setParameters(
|
||||
trackSelector
|
||||
.buildUponParameters()
|
||||
.setTunnelingEnabled(false)
|
||||
)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
10_000,
|
||||
30_000,
|
||||
5_000,
|
||||
5_000
|
||||
)
|
||||
.build()
|
||||
|
||||
// Configure RenderersFactory to use all available decoders and enable passthrough
|
||||
val renderersFactory = DefaultRenderersFactory(application)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
.setEnableDecoderFallback(true)
|
||||
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(playbackDataSourceFactory)
|
||||
|
||||
return ExoPlayer.Builder(application, renderersFactory)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setPauseAtEndOfMediaItems(true)
|
||||
.setLoadControl(loadControl)
|
||||
.setSeekParameters(SeekParameters.PREVIOUS_SYNC)
|
||||
.setAudioAttributes(audioAttributes, true)
|
||||
.build()
|
||||
.apply {
|
||||
playWhenReady = true
|
||||
pauseAtEndOfMediaItems = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package hu.bbara.purefin.core.player.preference
|
||||
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.model.TrackType
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrackMatcher @Inject constructor() {
|
||||
|
||||
/**
|
||||
* Finds the best matching audio track based on stored preferences.
|
||||
* Scoring: language (3) + channelCount (2) + label (1)
|
||||
* Requires minimum score of 3 (language match) to auto-select.
|
||||
*
|
||||
* @return The best matching TrackOption, or null if no good match found
|
||||
*/
|
||||
fun findBestAudioMatch(
|
||||
availableTracks: List<TrackOption>,
|
||||
preference: AudioTrackProperties
|
||||
): TrackOption? {
|
||||
if (availableTracks.isEmpty()) return null
|
||||
|
||||
val audioTracks = availableTracks.filter { it.type == TrackType.AUDIO }
|
||||
if (audioTracks.isEmpty()) return null
|
||||
|
||||
val scoredTracks = audioTracks.map { track ->
|
||||
track to calculateAudioScore(track, preference)
|
||||
}
|
||||
|
||||
val bestMatch = scoredTracks.maxByOrNull { it.second }
|
||||
|
||||
// Require minimum score of 3 (language match) to auto-select
|
||||
return if (bestMatch != null && bestMatch.second >= 3) {
|
||||
bestMatch.first
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best matching subtitle track based on stored preferences.
|
||||
* Scoring: language (3) + forced (2) + label (1)
|
||||
* Requires minimum score of 3 (language match) to auto-select.
|
||||
* Handles "Off" preference explicitly.
|
||||
*
|
||||
* @return The best matching TrackOption, or the "Off" option if preference.isOff is true, or null
|
||||
*/
|
||||
fun findBestSubtitleMatch(
|
||||
availableTracks: List<TrackOption>,
|
||||
preference: SubtitleTrackProperties
|
||||
): TrackOption? {
|
||||
if (availableTracks.isEmpty()) return null
|
||||
|
||||
val subtitleTracks = availableTracks.filter { it.type == TrackType.TEXT }
|
||||
if (subtitleTracks.isEmpty()) return null
|
||||
|
||||
// Handle "Off" preference
|
||||
if (preference.isOff) {
|
||||
return subtitleTracks.firstOrNull { it.isOff }
|
||||
}
|
||||
|
||||
val scoredTracks = subtitleTracks
|
||||
.filter { !it.isOff } // Exclude "Off" option when matching specific preferences
|
||||
.map { track ->
|
||||
track to calculateSubtitleScore(track, preference)
|
||||
}
|
||||
|
||||
val bestMatch = scoredTracks.maxByOrNull { it.second }
|
||||
|
||||
// Require minimum score of 3 (language match) to auto-select
|
||||
return if (bestMatch != null && bestMatch.second >= 3) {
|
||||
bestMatch.first
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateAudioScore(
|
||||
track: TrackOption,
|
||||
preference: AudioTrackProperties
|
||||
): Int {
|
||||
var score = 0
|
||||
|
||||
// Language match: 3 points
|
||||
if (track.language != null && track.language == preference.language) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// Channel count match: 2 points
|
||||
if (track.channelCount != null && track.channelCount == preference.channelCount) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
// Label match: 1 point
|
||||
if (track.label == preference.label) {
|
||||
score += 1
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
private fun calculateSubtitleScore(
|
||||
track: TrackOption,
|
||||
preference: SubtitleTrackProperties
|
||||
): Int {
|
||||
var score = 0
|
||||
|
||||
// Language match: 3 points
|
||||
if (track.language != null && track.language == preference.language) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// Forced flag match: 2 points
|
||||
if (track.forced == preference.forced) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
// Label match: 1 point
|
||||
if (track.label == preference.label) {
|
||||
score += 1
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package hu.bbara.purefin.core.player.preference
|
||||
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TrackPreferences(
|
||||
val mediaPreferences: Map<String, MediaTrackPreferences> = emptyMap()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaTrackPreferences(
|
||||
val mediaId: String,
|
||||
val audioPreference: AudioTrackProperties? = null,
|
||||
val subtitlePreference: SubtitleTrackProperties? = null
|
||||
) {
|
||||
companion object {
|
||||
fun empty(mediaId: String): MediaTrackPreferences {
|
||||
return MediaTrackPreferences(mediaId = mediaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AudioTrackProperties(
|
||||
val language: String? = null,
|
||||
val channelCount: Int? = null,
|
||||
val label: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubtitleTrackProperties(
|
||||
val language: String? = null,
|
||||
val forced: Boolean = false,
|
||||
val label: String? = null,
|
||||
val isOff: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package hu.bbara.purefin.core.player.preference
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.dataStoreFile
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class TrackPreferencesModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTrackPreferencesDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<TrackPreferences> {
|
||||
return DataStoreFactory.create(
|
||||
serializer = TrackPreferencesSerializer,
|
||||
produceFile = { context.dataStoreFile("track_preferences.json") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { TrackPreferencesSerializer.defaultValue }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTrackPreferencesRepository(
|
||||
trackPreferencesDataStore: DataStore<TrackPreferences>
|
||||
): TrackPreferencesRepository {
|
||||
return TrackPreferencesRepository(trackPreferencesDataStore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package hu.bbara.purefin.core.player.preference
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrackPreferencesRepository @Inject constructor(
|
||||
private val trackPreferencesDataStore: DataStore<TrackPreferences>
|
||||
) {
|
||||
val preferences: Flow<TrackPreferences> = trackPreferencesDataStore.data
|
||||
|
||||
fun getMediaPreferences(mediaId: String): Flow<MediaTrackPreferences> {
|
||||
return preferences.map { it.mediaPreferences[mediaId] ?: MediaTrackPreferences.empty(mediaId = mediaId) }
|
||||
}
|
||||
|
||||
suspend fun saveAudioPreference(
|
||||
mediaId: String,
|
||||
properties: AudioTrackProperties
|
||||
) {
|
||||
trackPreferencesDataStore.updateData { current ->
|
||||
val existingMediaPrefs = current.mediaPreferences[mediaId]
|
||||
val updatedMediaPrefs = existingMediaPrefs?.copy(audioPreference = properties)
|
||||
?: MediaTrackPreferences(
|
||||
mediaId = mediaId,
|
||||
audioPreference = properties
|
||||
)
|
||||
|
||||
current.copy(
|
||||
mediaPreferences = current.mediaPreferences + (mediaId to updatedMediaPrefs)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveSubtitlePreference(
|
||||
mediaId: String,
|
||||
properties: SubtitleTrackProperties
|
||||
) {
|
||||
trackPreferencesDataStore.updateData { current ->
|
||||
val existingMediaPrefs = current.mediaPreferences[mediaId]
|
||||
val updatedMediaPrefs = existingMediaPrefs?.copy(subtitlePreference = properties)
|
||||
?: MediaTrackPreferences(
|
||||
mediaId = mediaId,
|
||||
subtitlePreference = properties
|
||||
)
|
||||
|
||||
current.copy(
|
||||
mediaPreferences = current.mediaPreferences + (mediaId to updatedMediaPrefs)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package hu.bbara.purefin.core.player.preference
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object TrackPreferencesSerializer : Serializer<TrackPreferences> {
|
||||
override val defaultValue: TrackPreferences
|
||||
get() = TrackPreferences()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): TrackPreferences {
|
||||
try {
|
||||
return Json.decodeFromString<TrackPreferences>(
|
||||
input.readBytes().decodeToString()
|
||||
)
|
||||
} catch (serialization: SerializationException) {
|
||||
throw CorruptionException("Unable to read TrackPreferences", serialization)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: TrackPreferences, output: OutputStream) {
|
||||
output.write(
|
||||
Json.encodeToString(TrackPreferences.serializer(), t)
|
||||
.encodeToByteArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
enum class ControlsAutoHideBlocker {
|
||||
PLAYLIST,
|
||||
TRACK_PANEL
|
||||
}
|
||||
|
||||
internal sealed interface ControlsAutoHideCommand {
|
||||
data object Cancel : ControlsAutoHideCommand
|
||||
data class Schedule(val delayMs: Long) : ControlsAutoHideCommand
|
||||
}
|
||||
|
||||
internal class ControlsAutoHidePolicy(
|
||||
private val defaultDelayMs: Long
|
||||
) {
|
||||
private val blockers = mutableSetOf<ControlsAutoHideBlocker>()
|
||||
private var isPlaying = false
|
||||
|
||||
var controlsVisible: Boolean = true
|
||||
private set
|
||||
|
||||
var lastAutoHideDelayMs: Long = defaultDelayMs
|
||||
private set
|
||||
|
||||
fun onPlaybackChanged(isPlaying: Boolean): ControlsAutoHideCommand {
|
||||
this.isPlaying = isPlaying
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun setAutoHideDelay(delayMs: Long): ControlsAutoHideCommand {
|
||||
lastAutoHideDelayMs = delayMs
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun showControls(delayMs: Long? = null): ControlsAutoHideCommand {
|
||||
delayMs?.let { lastAutoHideDelayMs = it }
|
||||
controlsVisible = true
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility(): ControlsAutoHideCommand {
|
||||
controlsVisible = !controlsVisible
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
fun hideControls(): ControlsAutoHideCommand {
|
||||
controlsVisible = false
|
||||
return ControlsAutoHideCommand.Cancel
|
||||
}
|
||||
|
||||
fun setBlocked(
|
||||
blocker: ControlsAutoHideBlocker,
|
||||
blocked: Boolean
|
||||
): ControlsAutoHideCommand {
|
||||
if (blocked) {
|
||||
blockers += blocker
|
||||
controlsVisible = true
|
||||
} else {
|
||||
blockers -= blocker
|
||||
}
|
||||
return nextCommand()
|
||||
}
|
||||
|
||||
private fun nextCommand(): ControlsAutoHideCommand {
|
||||
return if (!controlsVisible || !isPlaying || blockers.isNotEmpty()) {
|
||||
ControlsAutoHideCommand.Cancel
|
||||
} else {
|
||||
ControlsAutoHideCommand.Schedule(lastAutoHideDelayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.EpisodeSeriesLookup
|
||||
import hu.bbara.purefin.core.data.PlayableMediaRepository
|
||||
import hu.bbara.purefin.core.player.manager.PlayerManager
|
||||
import hu.bbara.purefin.core.player.manager.ProgressManager
|
||||
import hu.bbara.purefin.core.player.model.MediaContext
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
import hu.bbara.purefin.core.player.model.TrackOption
|
||||
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PlayerViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val playerManager: PlayerManager,
|
||||
private val playableMediaRepository: PlayableMediaRepository,
|
||||
private val trackPreferencesRepository: TrackPreferencesRepository,
|
||||
private val episodeSeriesLookup: EpisodeSeriesLookup,
|
||||
private val progressManager: ProgressManager,
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L
|
||||
}
|
||||
|
||||
val player get() = playerManager.player
|
||||
|
||||
private val mediaId: String? = savedStateHandle["MEDIA_ID"]
|
||||
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _controlsVisible = MutableStateFlow(true)
|
||||
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
|
||||
|
||||
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
|
||||
private var autoHideJob: Job? = null
|
||||
private var lastNextUpMediaId: String? = null
|
||||
private var dataErrorMessage: String? = null
|
||||
|
||||
init {
|
||||
progressManager.bind(
|
||||
playerManager.playbackState,
|
||||
playerManager.progress,
|
||||
playerManager.metadata
|
||||
)
|
||||
observePlayerState()
|
||||
loadInitialMedia()
|
||||
}
|
||||
|
||||
private fun observePlayerState() {
|
||||
viewModelScope.launch {
|
||||
playerManager.playbackState.collect { state ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isPlaying = state.isPlaying,
|
||||
isBuffering = state.isBuffering,
|
||||
isEnded = state.isEnded,
|
||||
error = state.error ?: dataErrorMessage
|
||||
)
|
||||
}
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.onPlaybackChanged(state.isPlaying)
|
||||
)
|
||||
if (state.isEnded) {
|
||||
showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
playerManager.progress.collect { progress ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
durationMs = progress.durationMs,
|
||||
positionMs = progress.positionMs,
|
||||
bufferedMs = progress.bufferedMs,
|
||||
isLive = progress.isLive
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
playerManager.metadata.collect { metadata ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
title = metadata.title,
|
||||
subtitle = metadata.subtitle
|
||||
)
|
||||
}
|
||||
val currentMediaId = metadata.mediaId
|
||||
if (!currentMediaId.isNullOrEmpty() && currentMediaId != lastNextUpMediaId) {
|
||||
lastNextUpMediaId = currentMediaId
|
||||
loadNextUp(currentMediaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
playerManager.tracks.collect { tracks ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
audioTracks = tracks.audioTracks,
|
||||
textTracks = tracks.textTracks,
|
||||
qualityTracks = tracks.videoTracks,
|
||||
selectedAudioTrackId = tracks.selectedAudioTrackId,
|
||||
selectedTextTrackId = tracks.selectedTextTrackId,
|
||||
selectedQualityTrackId = tracks.selectedVideoTrackId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
playerManager.queue.collect { queue ->
|
||||
_uiState.update { it.copy(queue = queue) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInitialMedia() {
|
||||
val id = mediaId ?: return
|
||||
loadMediaById(id)
|
||||
}
|
||||
|
||||
fun loadMedia(id: String) {
|
||||
if (mediaId != null) return // Already loading from SavedStateHandle
|
||||
loadMediaById(id)
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
private fun loadMediaById(id: String) {
|
||||
val uuid = id.toUuidOrNull()
|
||||
if (uuid == null) {
|
||||
dataErrorMessage = "Invalid media id"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = playableMediaRepository.getMediaItem(uuid)
|
||||
if (result != null) {
|
||||
val (mediaItem, resumePositionMs) = result
|
||||
|
||||
val preferenceKey = episodeSeriesLookup.preferenceKeyFor(uuid)
|
||||
val preferences = trackPreferencesRepository.getMediaPreferences(preferenceKey).first()
|
||||
val mediaSegments = playableMediaRepository.getMediaSegments(uuid)
|
||||
|
||||
val mediaContext = MediaContext(
|
||||
mediaId = id,
|
||||
preferences = preferences,
|
||||
mediaSegments = mediaSegments
|
||||
)
|
||||
|
||||
playerManager.play(mediaItem, mediaContext)
|
||||
|
||||
// Seek to resume position after play() is called
|
||||
resumePositionMs?.let { playerManager.seekTo(it) }
|
||||
|
||||
if (dataErrorMessage != null) {
|
||||
dataErrorMessage = null
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
} else {
|
||||
dataErrorMessage = "Unable to load media"
|
||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNextUp(currentMediaId: String) {
|
||||
val uuid = currentMediaId.toUuidOrNull() ?: return
|
||||
viewModelScope.launch {
|
||||
val queuedIds = uiState.value.queue.map { it.id }.toSet()
|
||||
val items = playableMediaRepository.getNextUpMediaItems(
|
||||
episodeId = uuid,
|
||||
existingIds = queuedIds,
|
||||
)
|
||||
items.forEach { playerManager.addToQueue(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.togglePlayPause()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun pausePlayback() {
|
||||
playerManager.pausePlayback()
|
||||
}
|
||||
|
||||
fun resumePlayback() {
|
||||
playerManager.resumePlayback()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
playerManager.seekTo(positionMs)
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) {
|
||||
playerManager.seekBy(deltaMs)
|
||||
}
|
||||
|
||||
fun seekToLiveEdge() {
|
||||
playerManager.seekToLiveEdge()
|
||||
}
|
||||
|
||||
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun setControlsAutoHideBlocked(
|
||||
blocker: ControlsAutoHideBlocker,
|
||||
blocked: Boolean
|
||||
) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.setBlocked(blocker, blocked)
|
||||
)
|
||||
}
|
||||
|
||||
fun showControls(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.showControls(autoHideDelayMs)
|
||||
)
|
||||
}
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
applyControlsAutoHideCommand(
|
||||
controlsAutoHidePolicy.toggleControlsVisibility()
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyControlsAutoHideCommand(command: ControlsAutoHideCommand) {
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
autoHideJob?.cancel()
|
||||
autoHideJob = null
|
||||
if (command !is ControlsAutoHideCommand.Schedule) return
|
||||
autoHideJob = viewModelScope.launch {
|
||||
delay(command.delayMs)
|
||||
controlsAutoHidePolicy.hideControls()
|
||||
_controlsVisible.value = controlsAutoHidePolicy.controlsVisible
|
||||
}
|
||||
}
|
||||
|
||||
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.next()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.previous()
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun selectTrack(option: TrackOption) {
|
||||
playerManager.selectTrack(option)
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
playerManager.retry()
|
||||
}
|
||||
|
||||
fun playQueueItem(id: String, autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
|
||||
playerManager.playQueueItem(id)
|
||||
showControls(autoHideDelayMs)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
dataErrorMessage = null
|
||||
playerManager.clearError()
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
autoHideJob?.cancel()
|
||||
progressManager.release()
|
||||
playerManager.release()
|
||||
}
|
||||
|
||||
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package hu.bbara.purefin.core.ui.model
|
||||
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface MediaUiModel {
|
||||
val id: UUID
|
||||
val primaryText: String
|
||||
val secondaryText: String
|
||||
val description: String
|
||||
val primaryImageUrl: String
|
||||
val backdropImageUrl: String
|
||||
val progress: Float?
|
||||
get() = null
|
||||
val watched: Boolean
|
||||
get() = false
|
||||
}
|
||||
|
||||
class MovieUiModel: MediaUiModel {
|
||||
override val id: UUID
|
||||
override val primaryText: String
|
||||
override val secondaryText: String
|
||||
override val description: String
|
||||
private val prefixImageUrl: String
|
||||
override val primaryImageUrl: String
|
||||
get() {
|
||||
return ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = prefixImageUrl,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
}
|
||||
override val backdropImageUrl: String
|
||||
get() {
|
||||
return ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = prefixImageUrl,
|
||||
artworkKind = ArtworkKind.BACKDROP
|
||||
)
|
||||
}
|
||||
override val progress: Float?
|
||||
|
||||
constructor(movie: Movie) {
|
||||
id = movie.id
|
||||
primaryText = movie.title
|
||||
secondaryText = movie.year
|
||||
description = movie.synopsis
|
||||
prefixImageUrl = movie.imageUrlPrefix
|
||||
progress = (movie.progress?.toFloat() ?: 0f) / 100f
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createPlaceholder() : MovieUiModel {
|
||||
return MovieUiModel(
|
||||
Movie(
|
||||
id = UUID.randomUUID(),
|
||||
libraryId = UUID.randomUUID(),
|
||||
title = "Loading...",
|
||||
progress = 0.0,
|
||||
watched = false,
|
||||
year = "",
|
||||
rating = "",
|
||||
runtime = "",
|
||||
format = "",
|
||||
synopsis = "",
|
||||
imageUrlPrefix = "",
|
||||
audioTrack = "",
|
||||
subtitles = "",
|
||||
cast = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SeriesUiModel : MediaUiModel {
|
||||
override val id: UUID
|
||||
override val primaryText: String
|
||||
override val secondaryText: String
|
||||
override val description: String
|
||||
private val prefixImageUrl: String
|
||||
override val primaryImageUrl: String
|
||||
get() {
|
||||
return ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = prefixImageUrl,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
}
|
||||
override val backdropImageUrl: String
|
||||
get() {
|
||||
return ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = prefixImageUrl,
|
||||
artworkKind = ArtworkKind.BACKDROP
|
||||
)
|
||||
}
|
||||
|
||||
constructor(series: Series) {
|
||||
id = series.id
|
||||
primaryText = series.name
|
||||
secondaryText = "${series.seasonCount} seasons"
|
||||
description = series.synopsis
|
||||
prefixImageUrl = series.imageUrlPrefix
|
||||
}
|
||||
}
|
||||
|
||||
class EpisodeUiModel : MediaUiModel {
|
||||
override val id: UUID
|
||||
override val primaryText: String
|
||||
override val secondaryText: String
|
||||
override val description: String
|
||||
private val prefixImageUrl: String
|
||||
override val primaryImageUrl: String
|
||||
get() {
|
||||
return ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = prefixImageUrl,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
}
|
||||
override val backdropImageUrl: String
|
||||
get() {
|
||||
return ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = prefixImageUrl,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
}
|
||||
override val progress: Float?
|
||||
val seriesId: UUID
|
||||
val seasonId: UUID
|
||||
|
||||
constructor(episode: Episode) {
|
||||
id = episode.id
|
||||
primaryText = episode.title
|
||||
secondaryText = episode.releaseDate
|
||||
description = episode.synopsis
|
||||
prefixImageUrl = episode.imageUrlPrefix
|
||||
progress = (episode.progress?.toFloat() ?: 0f) / 100f
|
||||
seriesId = episode.seriesId
|
||||
seasonId = episode.seasonId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package hu.bbara.purefin.feature.browse.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import hu.bbara.purefin.core.model.Media
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.LibraryDto
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
|
||||
import hu.bbara.purefin.core.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.ui.model.SeriesUiModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
""
|
||||
)
|
||||
|
||||
val libraries = homeRepository.libraries.map { libraries ->
|
||||
libraries.map {
|
||||
LibraryItem(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
type = it.type,
|
||||
posterUrl = it.posterUrl,
|
||||
isEmpty = when(it.type) {
|
||||
LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty()
|
||||
LibraryKind.SERIES -> mediaCatalogReader.series.value.isEmpty()
|
||||
}
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val suggestions = combine(
|
||||
homeRepository.suggestions,
|
||||
mediaCatalogReader.movies,
|
||||
mediaCatalogReader.series,
|
||||
mediaCatalogReader.episodes
|
||||
) { list, moviesMap, seriesMap, episodesMap ->
|
||||
list.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
MovieUiModel(movie = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
EpisodeUiModel(episode = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val continueWatching = combine(
|
||||
homeRepository.continueWatching,
|
||||
mediaCatalogReader.movies,
|
||||
mediaCatalogReader.episodes
|
||||
) { list, moviesMap, episodesMap ->
|
||||
list.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
MovieUiModel(movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
EpisodeUiModel(episode = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val nextUp = combine(
|
||||
homeRepository.nextUp,
|
||||
mediaCatalogReader.episodes
|
||||
) { list, episodesMap ->
|
||||
list.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
EpisodeUiModel(episode = it)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val latestLibraryContent = combine(
|
||||
homeRepository.latestLibraryContent,
|
||||
mediaCatalogReader.movies,
|
||||
mediaCatalogReader.series,
|
||||
mediaCatalogReader.episodes
|
||||
) { libraryMap, moviesMap, seriesMap, episodesMap ->
|
||||
libraryMap.mapValues { (_, items) ->
|
||||
items.mapNotNull { media ->
|
||||
when (media) {
|
||||
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
|
||||
MovieUiModel(movie = it)
|
||||
}
|
||||
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
|
||||
EpisodeUiModel(episode = it)
|
||||
}
|
||||
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
is Media.SeasonMedia -> seriesMap[media.seriesId]?.let {
|
||||
SeriesUiModel(series = it)
|
||||
}
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
|
||||
fun onLibrarySelected(id: UUID, name: String) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.LibraryRoute(library = LibraryDto(id = id, name = name)))
|
||||
}
|
||||
}
|
||||
|
||||
fun onMediaSelected(mediaUiModel : MediaUiModel) {
|
||||
when (mediaUiModel) {
|
||||
is MovieUiModel -> onMovieSelected(mediaUiModel.id)
|
||||
is SeriesUiModel -> onSeriesSelected(mediaUiModel.id)
|
||||
is EpisodeUiModel -> onEpisodeSelected(mediaUiModel.seriesId, mediaUiModel.seasonId, mediaUiModel.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(
|
||||
id = movieId,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
private fun onSeriesSelected(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(
|
||||
id = seriesId,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEpisodeSelected(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun onResumed() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
mediaDownloadManager.syncSmartDownloads()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun onRefresh() {
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
homeRepository.refreshHomeData()
|
||||
} catch (e: Exception) {
|
||||
// Refresh is best-effort; don't crash on failure
|
||||
} finally {
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
mediaDownloadManager.syncSmartDownloads()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
userSessionRepository.setLoggedIn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package hu.bbara.purefin.feature.browse.home
|
||||
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
|
||||
sealed interface FocusableItem {
|
||||
val imageUrl: String
|
||||
val primaryText: String
|
||||
val secondaryText: String
|
||||
val description: String
|
||||
val id: UUID
|
||||
val type: MediaKind
|
||||
}
|
||||
|
||||
data class NextUpItem(
|
||||
val episode: Episode
|
||||
) : FocusableItem {
|
||||
override val id: UUID = episode.id
|
||||
override val type: MediaKind
|
||||
get() = MediaKind.EPISODE
|
||||
override val imageUrl: String
|
||||
get() = ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = episode.imageUrlPrefix,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
override val primaryText: String = episode.title
|
||||
override val secondaryText: String = episode.releaseDate
|
||||
override val description: String
|
||||
get() = episode.synopsis
|
||||
}
|
||||
|
||||
data class LibraryItem(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val type: LibraryKind,
|
||||
val posterUrl: String,
|
||||
val isEmpty: Boolean
|
||||
)
|
||||
|
||||
data class PosterItem(
|
||||
override val type: MediaKind,
|
||||
val movie: Movie? = null,
|
||||
val series: Series? = null,
|
||||
val episode: Episode? = null
|
||||
) : FocusableItem {
|
||||
override val id: UUID = when (type) {
|
||||
MediaKind.MOVIE -> movie!!.id
|
||||
MediaKind.EPISODE -> episode!!.id
|
||||
MediaKind.SERIES -> series!!.id
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
val title: String = when (type) {
|
||||
MediaKind.MOVIE -> movie!!.title
|
||||
MediaKind.EPISODE -> episode!!.title
|
||||
MediaKind.SERIES -> series!!.name
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
override val imageUrl: String = when (type) {
|
||||
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = movie!!.imageUrlPrefix,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = episode!!.imageUrlPrefix,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
MediaKind.SERIES -> ImageUrlBuilder.finishImageUrl(
|
||||
prefixImageUrl = series!!.imageUrlPrefix,
|
||||
artworkKind = ArtworkKind.PRIMARY
|
||||
)
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
override val primaryText: String
|
||||
get() = when (type) {
|
||||
MediaKind.MOVIE -> movie!!.title
|
||||
MediaKind.EPISODE -> episode!!.title
|
||||
MediaKind.SERIES -> series!!.name
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
override val secondaryText: String
|
||||
get() = when (type) {
|
||||
MediaKind.MOVIE -> movie!!.year
|
||||
MediaKind.EPISODE -> episode!!.releaseDate
|
||||
MediaKind.SERIES -> series!!.year
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
override val description: String
|
||||
get() = when (type) {
|
||||
MediaKind.MOVIE -> movie!!.synopsis
|
||||
MediaKind.EPISODE -> episode!!.synopsis
|
||||
MediaKind.SERIES -> series!!.synopsis
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
|
||||
fun watched() = when (type) {
|
||||
MediaKind.MOVIE -> movie!!.watched
|
||||
MediaKind.EPISODE -> episode!!.watched
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package hu.bbara.purefin.feature.browse.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.model.LibraryKind
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.ui.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.ui.model.SeriesUiModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val homeRepository: HomeRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
|
||||
libraryId, libraries ->
|
||||
if (libraryId == null) {
|
||||
return@combine emptyList()
|
||||
}
|
||||
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
|
||||
when (library.type) {
|
||||
LibraryKind.SERIES -> library.series!!.map { series ->
|
||||
SeriesUiModel(series)
|
||||
}
|
||||
LibraryKind.MOVIES -> library.movies!!.map { movie ->
|
||||
MovieUiModel(movie)
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { homeRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(
|
||||
id = movieId,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun onSeriesSelected(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(
|
||||
id = seriesId,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun selectLibrary(libraryId: UUID) {
|
||||
selectedLibrary.value = libraryId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package hu.bbara.purefin.feature.content.episode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EpisodeScreenViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
): ViewModel() {
|
||||
|
||||
private val _episodeId = MutableStateFlow<UUID?>(null)
|
||||
private val _seriesId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val episode: StateFlow<Episode?> = combine(
|
||||
_episodeId,
|
||||
mediaCatalogReader.episodes
|
||||
) { id, episodesMap ->
|
||||
id?.let { episodesMap[it] }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val seriesTitle: StateFlow<String?> = combine(
|
||||
_seriesId,
|
||||
mediaCatalogReader.series
|
||||
) { seriesId, seriesMap ->
|
||||
seriesId?.let { id -> seriesMap[id]?.name }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun onSeriesClick() {
|
||||
val seriesId = _seriesId.value ?: return
|
||||
navigationManager.navigate(Route.SeriesRoute(SeriesDto(id = seriesId)))
|
||||
}
|
||||
|
||||
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
_episodeId.value = episodeId
|
||||
_seriesId.value = seriesId
|
||||
viewModelScope.launch {
|
||||
mediaDownloadManager.observeDownloadState(episodeId.toString()).collect {
|
||||
_downloadState.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadClick() {
|
||||
val episodeId = _episodeId.value ?: return
|
||||
viewModelScope.launch {
|
||||
when (_downloadState.value) {
|
||||
is DownloadState.NotDownloaded, is DownloadState.Failed -> {
|
||||
mediaDownloadManager.downloadEpisode(episodeId)
|
||||
}
|
||||
is DownloadState.Downloading, is DownloadState.Downloaded -> {
|
||||
mediaDownloadManager.cancelEpisodeDownload(episodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package hu.bbara.purefin.feature.content.movie
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MovieScreenViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
): ViewModel() {
|
||||
|
||||
private val _movieId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
val movie: StateFlow<Movie?> = combine(
|
||||
_movieId,
|
||||
mediaCatalogReader.movies
|
||||
) { movieId, movies ->
|
||||
movieId?.let { movies[it] }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun onPlay() {
|
||||
val id = movie.value?.id?.toString() ?: return
|
||||
navigationManager.navigate(Route.PlayerRoute(mediaId = id))
|
||||
}
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectMovie(movieId: UUID) {
|
||||
_movieId.value = movieId
|
||||
viewModelScope.launch {
|
||||
mediaDownloadManager.observeDownloadState(movieId.toString()).collect {
|
||||
_downloadState.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadClick() {
|
||||
val movieId = movie.value?.id ?: return
|
||||
viewModelScope.launch {
|
||||
when (_downloadState.value) {
|
||||
is DownloadState.NotDownloaded, is DownloadState.Failed -> {
|
||||
mediaDownloadManager.downloadMovie(movieId)
|
||||
}
|
||||
is DownloadState.Downloading -> {
|
||||
mediaDownloadManager.cancelDownload(movieId)
|
||||
}
|
||||
is DownloadState.Downloaded -> {
|
||||
mediaDownloadManager.cancelDownload(movieId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package hu.bbara.purefin.feature.content.series
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SeriesViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val mediaDownloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _seriesId = MutableStateFlow<UUID?>(null)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val series: StateFlow<Series?> = _seriesId
|
||||
.flatMapLatest { id ->
|
||||
if (id != null) mediaCatalogReader.observeSeriesWithContent(id) else flowOf(null)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _seriesDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val seriesDownloadState: StateFlow<DownloadState> = _seriesDownloadState
|
||||
|
||||
private val _seasonDownloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||
val seasonDownloadState: StateFlow<DownloadState> = _seasonDownloadState
|
||||
|
||||
fun observeSeasonDownloadState(episodes: List<Episode>) {
|
||||
viewModelScope.launch {
|
||||
if (episodes.isEmpty()) {
|
||||
_seasonDownloadState.value = DownloadState.NotDownloaded
|
||||
return@launch
|
||||
}
|
||||
val flows = episodes.map { mediaDownloadManager.observeDownloadState(it.id.toString()) }
|
||||
combine(flows) { states -> aggregateDownloadStates(states.toList()) }
|
||||
.collect { _seasonDownloadState.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun observeSeriesDownloadState(series: Series) {
|
||||
viewModelScope.launch {
|
||||
val allEpisodes = series.seasons.flatMap { it.episodes }
|
||||
if (allEpisodes.isEmpty()) {
|
||||
_seriesDownloadState.value = DownloadState.NotDownloaded
|
||||
return@launch
|
||||
}
|
||||
val flows = allEpisodes.map { mediaDownloadManager.observeDownloadState(it.id.toString()) }
|
||||
combine(flows) { states -> aggregateDownloadStates(states.toList()) }
|
||||
.collect { _seriesDownloadState.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadSeason(episodes: List<Episode>) {
|
||||
viewModelScope.launch {
|
||||
mediaDownloadManager.downloadEpisodes(episodes.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
fun enableSmartDownload(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
mediaDownloadManager.enableSmartDownload(seriesId)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadSeries(series: Series) {
|
||||
viewModelScope.launch {
|
||||
val allEpisodeIds = series.seasons.flatMap { season ->
|
||||
season.episodes.map { it.id }
|
||||
}
|
||||
mediaDownloadManager.downloadEpisodes(allEpisodeIds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun aggregateDownloadStates(states: List<DownloadState>): DownloadState {
|
||||
if (states.isEmpty()) return DownloadState.NotDownloaded
|
||||
if (states.all { it is DownloadState.Downloaded }) return DownloadState.Downloaded
|
||||
if (states.any { it is DownloadState.Downloading }) {
|
||||
val avg = states.filterIsInstance<DownloadState.Downloading>()
|
||||
.map { it.progressPercent }
|
||||
.average()
|
||||
.toFloat()
|
||||
return DownloadState.Downloading(avg)
|
||||
}
|
||||
return DownloadState.NotDownloaded
|
||||
}
|
||||
|
||||
fun onSelectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
navigationManager.navigate(Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun onPlayEpisode(episodeId: UUID) {
|
||||
navigationManager.navigate(Route.PlayerRoute(mediaId = episodeId.toString()))
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectSeries(seriesId: UUID) {
|
||||
_seriesId.value = seriesId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package hu.bbara.purefin.feature.downloads
|
||||
|
||||
data class ActiveDownloadItem(
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val imageUrl: String,
|
||||
val progress: Float,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package hu.bbara.purefin.feature.downloads
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.OfflineCatalogReader
|
||||
import hu.bbara.purefin.core.download.MediaDownloadController
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.ui.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.ui.model.SeriesUiModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DownloadsViewModel @Inject constructor(
|
||||
private val offlineCatalogReader: OfflineCatalogReader,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val downloadManager: MediaDownloadController,
|
||||
) : ViewModel() {
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(id = movieId)
|
||||
))
|
||||
}
|
||||
|
||||
fun onSeriesSelected(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(id = seriesId)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Shared polling source: contentId → progress (0–100f). Starts when UI is subscribed.
|
||||
private val activeDownloadsMap = downloadManager.observeActiveDownloads()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
/** Items that are fully downloaded and not currently in progress. */
|
||||
val downloads = combine(
|
||||
offlineCatalogReader.movies,
|
||||
offlineCatalogReader.series,
|
||||
activeDownloadsMap
|
||||
) { movies, series, inProgress ->
|
||||
movies.values
|
||||
.filter { it.id.toString() !in inProgress }
|
||||
.map { MovieUiModel(it) } +
|
||||
series.values.map { SeriesUiModel(it) }
|
||||
}
|
||||
|
||||
/** Items currently being downloaded with their progress. */
|
||||
val activeDownloads = combine(
|
||||
activeDownloadsMap,
|
||||
offlineCatalogReader.movies,
|
||||
offlineCatalogReader.episodes,
|
||||
offlineCatalogReader.series
|
||||
) { inProgress, movies, episodes, seriesMap ->
|
||||
inProgress.mapNotNull { (contentId, progress) ->
|
||||
val id = try { UUID.fromString(contentId) } catch (e: Exception) { return@mapNotNull null }
|
||||
val movie = movies[id]
|
||||
if (movie != null) {
|
||||
ActiveDownloadItem(
|
||||
contentId = contentId,
|
||||
title = movie.title,
|
||||
subtitle = "",
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
progress = progress
|
||||
)
|
||||
} else {
|
||||
val episode = episodes[id]
|
||||
episode?.let {
|
||||
ActiveDownloadItem(
|
||||
contentId = contentId,
|
||||
title = it.title,
|
||||
subtitle = seriesMap[it.seriesId]?.name ?: "",
|
||||
imageUrl = ImageUrlBuilder.finishImageUrl(it.imageUrlPrefix, ArtworkKind.PRIMARY),
|
||||
progress = progress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun cancelDownload(contentId: String) {
|
||||
viewModelScope.launch {
|
||||
val id = try {
|
||||
UUID.fromString(contentId)
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
if (offlineCatalogReader.episodes.value.containsKey(id)) {
|
||||
downloadManager.cancelEpisodeDownload(id)
|
||||
} else {
|
||||
downloadManager.cancelDownload(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package hu.bbara.purefin.feature.login
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.AuthenticationRepository
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val authenticationRepository: AuthenticationRepository,
|
||||
) : ViewModel() {
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
private val _password = MutableStateFlow("")
|
||||
val password: StateFlow<String> = _password.asStateFlow()
|
||||
private val _url = MutableStateFlow("")
|
||||
val url: StateFlow<String> = _url.asStateFlow()
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_url.value = userSessionRepository.serverUrl.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun setUrl(url: String) {
|
||||
_url.value = url
|
||||
}
|
||||
|
||||
fun setUsername(username: String) {
|
||||
_username.value = username
|
||||
}
|
||||
|
||||
fun setPassword(password: String) {
|
||||
_password.value = password
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
suspend fun clearFields() {
|
||||
userSessionRepository.setServerUrl("");
|
||||
_username.value = ""
|
||||
_password.value = ""
|
||||
}
|
||||
|
||||
suspend fun login(): Boolean {
|
||||
_errorMessage.value = null
|
||||
userSessionRepository.setServerUrl(url.value)
|
||||
val success = authenticationRepository.login(url.value, username.value, password.value)
|
||||
if (!success) {
|
||||
_errorMessage.value = "Login failed. Check your server URL, username, and password."
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package hu.bbara.purefin.feature.search
|
||||
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import java.util.UUID
|
||||
|
||||
data class SearchResult(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val posterUrl: String,
|
||||
val type: MediaKind,
|
||||
) {
|
||||
companion object {
|
||||
fun create(movie: Movie, imageUrl: String): SearchResult {
|
||||
return SearchResult(
|
||||
id = movie.id,
|
||||
title = movie.title,
|
||||
posterUrl = imageUrl,
|
||||
type = MediaKind.MOVIE,
|
||||
)
|
||||
}
|
||||
|
||||
fun create(series: Series, imageUrl: String): SearchResult {
|
||||
return SearchResult(
|
||||
id = series.id,
|
||||
title = series.name,
|
||||
posterUrl = imageUrl,
|
||||
type = MediaKind.SERIES,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package hu.bbara.purefin.feature.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.data.session.UserSessionRepository
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import hu.bbara.purefin.core.image.ArtworkKind
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@OptIn(FlowPreview::class)
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
|
||||
val searchResult = _searchResult.asStateFlow()
|
||||
|
||||
private val query = MutableStateFlow("")
|
||||
|
||||
init {
|
||||
combine(
|
||||
query.debounce(300).distinctUntilChanged(),
|
||||
mediaCatalogReader.movies,
|
||||
mediaCatalogReader.series
|
||||
) { currentQuery, movies, series ->
|
||||
val filteredMovies = movies.filter {
|
||||
it.value.title.contains(currentQuery, ignoreCase = true)
|
||||
}
|
||||
val filteredSeries = series.filter {
|
||||
it.value.name.contains(currentQuery, ignoreCase = true)
|
||||
}
|
||||
_searchResult.value = filteredMovies.values.map {
|
||||
SearchResult.create(it, createImageUrl(it.id))
|
||||
} + filteredSeries.values.map {
|
||||
SearchResult.create(it, createImageUrl(it.id))
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
this.query.value = query
|
||||
}
|
||||
|
||||
private suspend fun createImageUrl(id: UUID) : String {
|
||||
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
||||
ArtworkKind.PRIMARY)
|
||||
}
|
||||
}
|
||||
5
domain/src/main/res/values/strings.xml
Normal file
5
domain/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="download_channel_name">Downloads</string>
|
||||
<string name="download_channel_description">Purefin offline downloads</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,43 @@
|
||||
package hu.bbara.purefin.core.download
|
||||
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DownloadModuleTest {
|
||||
|
||||
@Test
|
||||
fun `download cache keeps no-op evictor`() {
|
||||
assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phone playback data source reads download cache and writes upstream only`() {
|
||||
val upstreamFactory = FakeDataSourceFactory()
|
||||
val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory)
|
||||
|
||||
assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly"))
|
||||
assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory"))
|
||||
assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory"))
|
||||
}
|
||||
|
||||
private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean {
|
||||
return readPrivateField(instance, fieldName) as Boolean
|
||||
}
|
||||
|
||||
private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? {
|
||||
val field = instance.javaClass.getDeclaredField(fieldName)
|
||||
field.isAccessible = true
|
||||
return field.get(instance)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeDataSourceFactory : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource {
|
||||
throw UnsupportedOperationException("Unused in unit tests")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package hu.bbara.purefin.core.navigation
|
||||
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RouteSerializationTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
@Test
|
||||
fun `episode route round trips with nested dto ids`() {
|
||||
val route: Route = Route.EpisodeRoute(
|
||||
item = EpisodeDto(
|
||||
id = UUID.randomUUID(),
|
||||
seasonId = UUID.randomUUID(),
|
||||
seriesId = UUID.randomUUID(),
|
||||
)
|
||||
)
|
||||
|
||||
assertRoundTrip(route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library route round trips`() {
|
||||
val route: Route = Route.LibraryRoute(
|
||||
library = LibraryDto(
|
||||
id = UUID.randomUUID(),
|
||||
name = "Shows",
|
||||
)
|
||||
)
|
||||
|
||||
assertRoundTrip(route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie and series routes round trip`() {
|
||||
assertRoundTrip(
|
||||
Route.MovieRoute(
|
||||
item = MovieDto(id = UUID.randomUUID())
|
||||
)
|
||||
)
|
||||
assertRoundTrip(
|
||||
Route.SeriesRoute(
|
||||
item = SeriesDto(id = UUID.randomUUID())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `singleton routes round trip`() {
|
||||
assertRoundTrip(Route.Home)
|
||||
assertRoundTrip(Route.LoginRoute)
|
||||
assertRoundTrip(Route.PlayerRoute(mediaId = UUID.randomUUID().toString()))
|
||||
}
|
||||
|
||||
private fun assertRoundTrip(route: Route) {
|
||||
val encoded = json.encodeToString(route)
|
||||
val decoded = json.decodeFromString<Route>(encoded)
|
||||
|
||||
assertEquals(route, decoded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package hu.bbara.purefin.core.player.viewmodel
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ControlsAutoHidePolicyTest {
|
||||
|
||||
@Test
|
||||
fun activeBlockerCancelsScheduledAutoHide() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(3_500L),
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
)
|
||||
assertTrue(policy.controlsVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistBlockerPreventsAutoHideUntilCleared() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackPanelBlockerUsesRememberedDelayWhenRemoved() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoHideResumesOnlyAfterLastBlockerIsCleared() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.showControls(delayMs = 5_000L)
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = true)
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = false)
|
||||
)
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Schedule(5_000L),
|
||||
policy.setBlocked(ControlsAutoHideBlocker.TRACK_PANEL, blocked = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blockingStateMakesHiddenControlsVisibleAgain() {
|
||||
val policy = ControlsAutoHidePolicy(defaultDelayMs = 3_500L)
|
||||
|
||||
policy.onPlaybackChanged(isPlaying = true)
|
||||
policy.toggleControlsVisibility()
|
||||
|
||||
assertEquals(
|
||||
ControlsAutoHideCommand.Cancel,
|
||||
policy.setBlocked(ControlsAutoHideBlocker.PLAYLIST, blocked = true)
|
||||
)
|
||||
assertTrue(policy.controlsVisible)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user