Collapse shared modules into data domain and ui-common

This commit is contained in:
2026-04-23 20:42:59 +02:00
parent 59a471c535
commit 3f7c1966af
180 changed files with 21 additions and 476 deletions

45
domain/build.gradle.kts Normal file
View 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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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 (0100)
* 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
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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,
)

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}

View File

@@ -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>
)

View File

@@ -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,
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -0,0 +1,6 @@
package hu.bbara.purefin.core.player.model
enum class SegmentStatus {
START,
END
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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()
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -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 (0100f). 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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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)
}
}

View 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>

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}