Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>`), 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -44,4 +49,6 @@ abstract class MpviumDatabase : RoomDatabase() {
abstract fun playlistDao(): PlaylistDao

abstract fun libraryIndexDao(): LibraryIndexDao

abstract fun customSeriesDao(): CustomSeriesDao
}
Original file line number Diff line number Diff line change
@@ -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<CustomSeriesEntity?>

@Query("SELECT * FROM custom_series ORDER BY updatedAt DESC")
fun observeAllSeries(): Flow<List<CustomSeriesEntity>>

@Query("SELECT * FROM custom_series ORDER BY updatedAt DESC")
suspend fun getAllSeries(): List<CustomSeriesEntity>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: CustomSeriesItemEntity): Long

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<CustomSeriesItemEntity>)

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

@Query("SELECT * FROM custom_series_item WHERE seriesId = :seriesId ORDER BY position ASC")
fun observeItemsForSeries(seriesId: Int): Flow<List<CustomSeriesItemEntity>>

@Query("SELECT * FROM custom_series_item ORDER BY seriesId ASC, position ASC")
suspend fun getAllItems(): List<CustomSeriesItemEntity>

@Query("SELECT * FROM custom_series_item ORDER BY seriesId ASC, position ASC")
fun observeAllItems(): Flow<List<CustomSeriesItemEntity>>

@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<CustomSeriesItemEntity>) {
deleteItemsBySeriesId(seriesId)
insertItems(items)
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<CustomSeriesItemDraft> = 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<CustomSeriesItemDraft>,
) {
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<Video>) {
val existing = customSeriesDao.getSeriesById(seriesId) ?: return
val currentItems = customSeriesDao.getItemsForSeries(seriesId)
val maxPosition = currentItems.maxOfOrNull { it.position } ?: -1
val maxSeason = currentItems.maxOfOrNull { it.season } ?: 1
var nextEpisode = (currentItems.filter { it.season == maxSeason }.maxOfOrNull { it.episode } ?: 0) + 1

val newItems = videos.mapIndexed { index, video ->
val parsed = MediaInfoParser.parse(video.displayName)
val season = parsed.season ?: maxSeason
val episode = if (parsed.season != null && parsed.episode != null) {
parsed.episode
} else {
val ep = nextEpisode
nextEpisode++
ep
}

CustomSeriesItemEntity(
seriesId = seriesId,
filePath = video.path,
fileName = video.displayName,
season = season,
episode = episode,
position = maxPosition + 1 + index,
)
}

customSeriesDao.insertItems(newItems)
customSeriesDao.updateSeries(existing.copy(updatedAt = System.currentTimeMillis()))
}

suspend fun getSeriesById(id: Int): CustomSeriesEntity? = customSeriesDao.getSeriesById(id)

fun observeSeriesById(id: Int): Flow<CustomSeriesEntity?> = customSeriesDao.observeSeriesById(id)

suspend fun getAllSeries(): List<CustomSeriesEntity> = customSeriesDao.getAllSeries()

fun observeAllSeries(): Flow<List<CustomSeriesEntity>> = customSeriesDao.observeAllSeries()

suspend fun getItemsForSeries(seriesId: Int): List<CustomSeriesItemEntity> =
customSeriesDao.getItemsForSeries(seriesId)

fun observeItemsForSeries(seriesId: Int): Flow<List<CustomSeriesItemEntity>> =
customSeriesDao.observeItemsForSeries(seriesId)

suspend fun getAllItems(): List<CustomSeriesItemEntity> = customSeriesDao.getAllItems()

fun observeAllItems(): Flow<List<CustomSeriesItemEntity>> = customSeriesDao.observeAllItems()

suspend fun getAllClaimedFilePaths(): Set<String> =
customSeriesDao.getAllItems().mapTo(HashSet()) { it.filePath }
}
Loading
Loading