From 7e720b292d7b9574de523ffd635de6bde763e950 Mon Sep 17 00:00:00 2001 From: aryan447 Date: Sat, 7 Nov 2026 12:34:39 +0530 Subject: [PATCH 1/3] feat(series): support user-created custom shows and episode ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add custom_series and custom_series_item Room v10 tables with MIGRATION_9_10 - Exclude claimed custom series videos from scanner grouping to avoid duplicate entries - Provide 'Add to series…' in Folders/Files selection mode and 'Create series' in Shows - Add CustomSeriesBuilderScreen with drag-reorder, explicit season/episodes, and poster picker - Render custom series in SeriesDetailScreen with Edit and non-destructive Delete grouping - Add unit tests in CustomSeriesTest and update CHANGELOG.md --- CHANGELOG.md | 4 + .../mpvium/database/MpviumDatabase.kt | 9 +- .../mpvium/database/dao/CustomSeriesDao.kt | 82 ++ .../database/entities/CustomSeriesEntity.kt | 15 + .../entities/CustomSeriesItemEntity.kt | 31 + .../repository/CustomSeriesRepository.kt | 167 ++++ .../app/aryan447/mpvium/di/DatabaseModule.kt | 69 +- .../mpvium/domain/streaming/SeriesDetector.kt | 147 ++- .../ui/browser/components/BrowserTopBar.kt | 18 + .../browser/components/FloatingBottomBar.kt | 25 + .../ui/browser/dialogs/AddToSeriesDialog.kt | 195 ++++ .../filesystem/FileSystemBrowserScreen.kt | 24 + .../ui/browser/videolist/VideoListScreen.kt | 21 + .../series/CustomSeriesBuilderScreen.kt | 923 ++++++++++++++++++ .../ui/streaming/series/SeriesDetailScreen.kt | 116 ++- .../ui/streaming/series/SeriesGridScreen.kt | 13 +- app/src/main/res/values/strings.xml | 21 + .../domain/streaming/CustomSeriesTest.kt | 170 ++++ 18 files changed, 2026 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/app/aryan447/mpvium/database/dao/CustomSeriesDao.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesEntity.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesItemEntity.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/database/repository/CustomSeriesRepository.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/browser/dialogs/AddToSeriesDialog.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/streaming/series/CustomSeriesBuilderScreen.kt create mode 100644 app/src/test/java/app/aryan447/mpvium/domain/streaming/CustomSeriesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6dd758..ec4a2efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ### Added - Modern player layout option (Settings → Appearance → Player Layout): modeled after Apple TV's native player interface. Features a top-left floating glass pill with close (✕) and PiP buttons; expandable top-right Volume & Brightness controls with live scrub percentage readout and round toggle button; center floating circular glass transport controls (10s skip and hero play/pause); bottom-left two-line show title (episode name on top, series name with chevron below for TV shows, or single clean movie title for movies); customizable bottom-right actions pill (Subtitles, Audio Track, Explain, and Menu); theme-colored seekbar with elapsed/remaining timers, chapter dividers gated by settings, and system gesture navigation clearance; and tactile haptic feedback across all controls. +- Custom series: create and edit your own TV shows from any videos with custom title, release year, poster cover, and drag-and-drop episode reordering with explicit season and episode numbering. +- Custom series entry points: selection mode in Folders and Files tabs now features "Add to series…" next to "Add to playlist", and the Shows tab header includes a direct "Create series" action with helpful empty-state guidance. +- Series detail screen integration: custom series render identically to auto-detected shows with deterministic identifiers (`custom:`), with dedicated Edit and safe Delete actions that remove only the series grouping without touching underlying video files. +- Library exclusion overlay: videos claimed by a custom series are automatically excluded from automatic scanner grouping to eliminate duplicate entries in TV shows and movies. - Unified Titles tab combining TV shows and movies into a single browsing experience at canonical index 1, with a segment control (All, TV Shows, Movies) as the primary filter row, watch-state and duration filter dropdown buttons, normalized sorting (Name, Recently added, Rating, Longest, Shortest), and layout toggle (grid/list). - Option under Settings → Appearance → Bottom navigation to separate TV Shows and Movies into distinct tabs instead of the unified Titles tab, with individual visibility toggles for each. - Reserved historical canonical index 2 for launcher shortcuts and external tab requests, automatically opening Titles with the Movies filter preselected. diff --git a/app/src/main/java/app/aryan447/mpvium/database/MpviumDatabase.kt b/app/src/main/java/app/aryan447/mpvium/database/MpviumDatabase.kt index b9e00c35..8214c22f 100644 --- a/app/src/main/java/app/aryan447/mpvium/database/MpviumDatabase.kt +++ b/app/src/main/java/app/aryan447/mpvium/database/MpviumDatabase.kt @@ -4,12 +4,15 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import app.aryan447.mpvium.database.converters.NetworkProtocolConverter +import app.aryan447.mpvium.database.dao.CustomSeriesDao import app.aryan447.mpvium.database.dao.LibraryIndexDao import app.aryan447.mpvium.database.dao.NetworkConnectionDao import app.aryan447.mpvium.database.dao.PlaybackStateDao import app.aryan447.mpvium.database.dao.PlaylistDao import app.aryan447.mpvium.database.dao.RecentlyPlayedDao import app.aryan447.mpvium.database.dao.VideoMetadataDao +import app.aryan447.mpvium.database.entities.CustomSeriesEntity +import app.aryan447.mpvium.database.entities.CustomSeriesItemEntity import app.aryan447.mpvium.database.entities.LibraryIndexEntity import app.aryan447.mpvium.database.entities.PlaybackStateEntity import app.aryan447.mpvium.database.entities.PlaylistEntity @@ -27,8 +30,10 @@ import app.aryan447.mpvium.domain.network.NetworkConnection PlaylistEntity::class, PlaylistItemEntity::class, LibraryIndexEntity::class, + CustomSeriesEntity::class, + CustomSeriesItemEntity::class, ], - version = 9, + version = 10, exportSchema = true, ) @TypeConverters(NetworkProtocolConverter::class) @@ -44,4 +49,6 @@ abstract class MpviumDatabase : RoomDatabase() { abstract fun playlistDao(): PlaylistDao abstract fun libraryIndexDao(): LibraryIndexDao + + abstract fun customSeriesDao(): CustomSeriesDao } diff --git a/app/src/main/java/app/aryan447/mpvium/database/dao/CustomSeriesDao.kt b/app/src/main/java/app/aryan447/mpvium/database/dao/CustomSeriesDao.kt new file mode 100644 index 00000000..a8339d23 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/database/dao/CustomSeriesDao.kt @@ -0,0 +1,82 @@ +package app.aryan447.mpvium.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import app.aryan447.mpvium.database.entities.CustomSeriesEntity +import app.aryan447.mpvium.database.entities.CustomSeriesItemEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CustomSeriesDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSeries(series: CustomSeriesEntity): Long + + @Update + suspend fun updateSeries(series: CustomSeriesEntity) + + @Delete + suspend fun deleteSeries(series: CustomSeriesEntity) + + @Query("DELETE FROM custom_series WHERE id = :seriesId") + suspend fun deleteSeriesById(seriesId: Int) + + @Query("SELECT * FROM custom_series WHERE id = :seriesId") + suspend fun getSeriesById(seriesId: Int): CustomSeriesEntity? + + @Query("SELECT * FROM custom_series WHERE id = :seriesId") + fun observeSeriesById(seriesId: Int): Flow + + @Query("SELECT * FROM custom_series ORDER BY updatedAt DESC") + fun observeAllSeries(): Flow> + + @Query("SELECT * FROM custom_series ORDER BY updatedAt DESC") + suspend fun getAllSeries(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: CustomSeriesItemEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItems(items: List) + + @Update + suspend fun updateItem(item: CustomSeriesItemEntity) + + @Delete + suspend fun deleteItem(item: CustomSeriesItemEntity) + + @Query("DELETE FROM custom_series_item WHERE seriesId = :seriesId") + suspend fun deleteItemsBySeriesId(seriesId: Int) + + @Query("DELETE FROM custom_series_item WHERE id = :itemId") + suspend fun deleteItemById(itemId: Int) + + @Query("SELECT * FROM custom_series_item WHERE seriesId = :seriesId ORDER BY position ASC") + suspend fun getItemsForSeries(seriesId: Int): List + + @Query("SELECT * FROM custom_series_item WHERE seriesId = :seriesId ORDER BY position ASC") + fun observeItemsForSeries(seriesId: Int): Flow> + + @Query("SELECT * FROM custom_series_item ORDER BY seriesId ASC, position ASC") + suspend fun getAllItems(): List + + @Query("SELECT * FROM custom_series_item ORDER BY seriesId ASC, position ASC") + fun observeAllItems(): Flow> + + @Query("SELECT MAX(position) FROM custom_series_item WHERE seriesId = :seriesId") + suspend fun getMaxPosition(seriesId: Int): Int? + + @Query("UPDATE custom_series_item SET position = :newPosition WHERE id = :itemId") + suspend fun updateItemPosition(itemId: Int, newPosition: Int) + + @Transaction + suspend fun replaceSeriesItems(seriesId: Int, items: List) { + deleteItemsBySeriesId(seriesId) + insertItems(items) + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesEntity.kt b/app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesEntity.kt new file mode 100644 index 00000000..324d7e80 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesEntity.kt @@ -0,0 +1,15 @@ +package app.aryan447.mpvium.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "custom_series") +data class CustomSeriesEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val title: String, + val year: String? = null, + val posterPath: String? = null, + val tmdbId: Int? = null, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesItemEntity.kt b/app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesItemEntity.kt new file mode 100644 index 00000000..62fc38cd --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/database/entities/CustomSeriesItemEntity.kt @@ -0,0 +1,31 @@ +package app.aryan447.mpvium.database.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "custom_series_item", + foreignKeys = [ + ForeignKey( + entity = CustomSeriesEntity::class, + parentColumns = ["id"], + childColumns = ["seriesId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["seriesId"]), + Index(value = ["filePath"]), + ], +) +data class CustomSeriesItemEntity( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + val seriesId: Int, + val filePath: String, + val fileName: String, + val season: Int, + val episode: Int, + val position: Int, +) diff --git a/app/src/main/java/app/aryan447/mpvium/database/repository/CustomSeriesRepository.kt b/app/src/main/java/app/aryan447/mpvium/database/repository/CustomSeriesRepository.kt new file mode 100644 index 00000000..97a7d0e4 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/database/repository/CustomSeriesRepository.kt @@ -0,0 +1,167 @@ +package app.aryan447.mpvium.database.repository + +import app.aryan447.mpvium.database.dao.CustomSeriesDao +import app.aryan447.mpvium.database.entities.CustomSeriesEntity +import app.aryan447.mpvium.database.entities.CustomSeriesItemEntity +import app.aryan447.mpvium.domain.media.model.Video +import app.aryan447.mpvium.domain.streaming.StreamingArtworkStore +import app.aryan447.mpvium.utils.media.MediaInfoParser +import kotlinx.coroutines.flow.Flow + +data class CustomSeriesItemDraft( + val id: Int = 0, + val filePath: String, + val fileName: String, + val season: Int = 1, + val episode: Int = 1, + val position: Int = 0, +) + +class CustomSeriesRepository( + private val customSeriesDao: CustomSeriesDao, + private val artworkStore: StreamingArtworkStore, +) { + + suspend fun createSeries( + title: String, + year: String? = null, + posterPath: String? = null, + tmdbId: Int? = null, + items: List = emptyList(), + ): Long { + val now = System.currentTimeMillis() + val cleanTitle = title.trim() + val cleanYear = year?.trim()?.takeIf { it.isNotBlank() } + val cleanPoster = posterPath?.trim()?.takeIf { it.isNotBlank() } + + val seriesId = customSeriesDao.insertSeries( + CustomSeriesEntity( + title = cleanTitle, + year = cleanYear, + posterPath = cleanPoster, + tmdbId = tmdbId, + createdAt = now, + updatedAt = now, + ) + ) + + if (items.isNotEmpty()) { + val entities = items.mapIndexed { index, draft -> + CustomSeriesItemEntity( + seriesId = seriesId.toInt(), + filePath = draft.filePath, + fileName = draft.fileName, + season = draft.season.coerceAtLeast(0), + episode = draft.episode.coerceAtLeast(1), + position = if (draft.position >= 0) draft.position else index, + ) + } + customSeriesDao.insertItems(entities) + } + + return seriesId + } + + suspend fun updateSeries( + id: Int, + title: String, + year: String? = null, + posterPath: String? = null, + tmdbId: Int? = null, + items: List, + ) { + val existing = customSeriesDao.getSeriesById(id) ?: return + val now = System.currentTimeMillis() + val cleanTitle = title.trim() + val cleanYear = year?.trim()?.takeIf { it.isNotBlank() } + val cleanPoster = posterPath?.trim()?.takeIf { it.isNotBlank() } + + // Clean up old artwork if poster was changed or removed + if (existing.posterPath != null && existing.posterPath != cleanPoster) { + artworkStore.delete(listOf(existing.posterPath)) + } + + customSeriesDao.updateSeries( + existing.copy( + title = cleanTitle, + year = cleanYear, + posterPath = cleanPoster, + tmdbId = tmdbId ?: existing.tmdbId, + updatedAt = now, + ) + ) + + val entities = items.mapIndexed { index, draft -> + CustomSeriesItemEntity( + seriesId = id, + filePath = draft.filePath, + fileName = draft.fileName, + season = draft.season.coerceAtLeast(0), + episode = draft.episode.coerceAtLeast(1), + position = index, + ) + } + customSeriesDao.replaceSeriesItems(id, entities) + } + + suspend fun deleteSeries(id: Int) { + val existing = customSeriesDao.getSeriesById(id) ?: return + if (!existing.posterPath.isNullOrBlank()) { + artworkStore.delete(listOf(existing.posterPath)) + } + customSeriesDao.deleteSeries(existing) + } + + suspend fun addVideosToSeries(seriesId: Int, videos: List