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 @@ -7,6 +7,10 @@
- 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.
- Audio library tab (`MainTab.AUDIO` at canonical index 6): dedicated library for local audio with MediaStore scanning across albums, artists, and tracks, plus album artwork support without polluting video folders or tabs. Automatically hides when no audio exists on device while staying toggleable in Settings → Appearance.
- Audio browsing & layouts: browse by Albums (grid or list view, persisted independently via `layout_audio`), All tracks, and Artists, with album detail view and queue playback.
- Audio player surface: dedicated audio-mode player interface featuring album artwork cover, title, artist, seekbar, queue sheet, and playback controls with video gestures disabled.
- Audio background playback: persistent background playback with media notification and lock screen controls via `MediaPlaybackService`, with graceful handling when `POST_NOTIFICATIONS` permission is denied.
- 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
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/app/aryan447/mpvium/di/DomainModule.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.aryan447.mpvium.di

import app.aryan447.mpvium.domain.anime4k.Anime4KManager
import app.aryan447.mpvium.domain.audio.AudioRepository
import app.aryan447.mpvium.domain.streaming.LibraryIndexRepository
import app.aryan447.mpvium.domain.streaming.SeriesDetector
import app.aryan447.mpvium.domain.streaming.StreamingArtworkStore
Expand Down Expand Up @@ -33,4 +34,5 @@ val domainModule = module {
single { StreamingArtworkStore(androidContext(), get()) }
single { StreamingMetadataRepository(androidContext(), get(), get(), get(), get()) }
single { SubtitleProfileRepository(androidContext(), get(), get()) }
single { AudioRepository(androidContext()) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package app.aryan447.mpvium.domain.audio

import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext

class AudioRepository(
private val context: Context,
) {
private val _libraryData = MutableStateFlow(AudioLibraryData())
val libraryData: StateFlow<AudioLibraryData> = _libraryData.asStateFlow()

private val _hasAudio = MutableStateFlow(false)
val hasAudio: StateFlow<Boolean> = _hasAudio.asStateFlow()

private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

suspend fun checkHasAudio(): Boolean = withContext(Dispatchers.IO) {
val present = AudioScanUtils.hasAudio(context)
_hasAudio.value = present
present
}

suspend fun refresh(): AudioLibraryData = withContext(Dispatchers.IO) {
_isLoading.value = true
try {
val data = AudioScanUtils.scanAudio(context)
_libraryData.value = data
_hasAudio.value = data.tracks.isNotEmpty()
data
} finally {
_isLoading.value = false
}
}

fun getLibrarySnapshot(): AudioLibraryData = _libraryData.value
}
212 changes: 212 additions & 0 deletions app/src/main/java/app/aryan447/mpvium/domain/audio/AudioScanUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package app.aryan447.mpvium.domain.audio

import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import app.aryan447.mpvium.domain.media.model.LocalAlbum
import app.aryan447.mpvium.domain.media.model.LocalAudioArtist
import app.aryan447.mpvium.domain.media.model.LocalAudioTrack
import app.aryan447.mpvium.utils.storage.FileTypeUtils
import app.aryan447.mpvium.utils.storage.VideoScanUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale

data class AudioLibraryData(
val tracks: List<LocalAudioTrack> = emptyList(),
val albums: List<LocalAlbum> = emptyList(),
val artists: List<LocalAudioArtist> = emptyList(),
)

object AudioScanUtils {
private const val TAG = "AudioScanUtils"
private val ALBUM_ART_BASE_URI: Uri = Uri.parse("content://media/external/audio/albumart")

/**
* Quick check whether any audio files exist on the device.
*/
fun hasAudio(context: Context): Boolean {
return try {
val projection = arrayOf(MediaStore.Audio.Media._ID)
val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
context.contentResolver.query(
uri,
projection,
null,
null,
"${MediaStore.Audio.Media._ID} ASC LIMIT 1",
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: Exception) {
Log.w(TAG, "Failed to check for audio presence", e)
false
}
}

/**
* Scans all audio files via MediaStore.Audio, grouping them into tracks,
* albums (composite id from album + artist), and artists.
*/
suspend fun scanAudio(context: Context): AudioLibraryData = withContext(Dispatchers.IO) {
val tracks = mutableListOf<LocalAudioTrack>()

val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATE_MODIFIED,
MediaStore.Audio.Media.DATE_ADDED,
MediaStore.Audio.Media.MIME_TYPE,
MediaStore.Audio.Media.ARTIST_ID,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.TRACK,
MediaStore.Audio.Media.YEAR,
)

try {
context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
"${MediaStore.Audio.Media.TITLE} ASC",
)?.use { cursor ->
val idCol = cursor.getColumnIndex(MediaStore.Audio.Media._ID)
val titleCol = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE)
val nameCol = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
val dataCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATA)
val sizeCol = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
val durCol = cursor.getColumnIndex(MediaStore.Audio.Media.DURATION)
val modCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED)
val addCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_ADDED)
val mimeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
val artistIdCol = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST_ID)
val artistCol = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST)
val albumIdCol = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID)
val albumCol = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM)
val trackCol = cursor.getColumnIndex(MediaStore.Audio.Media.TRACK)
val yearCol = cursor.getColumnIndex(MediaStore.Audio.Media.YEAR)

while (cursor.moveToNext()) {
val path = if (dataCol >= 0) cursor.getString(dataCol) ?: "" else ""
val file = if (path.isNotBlank()) File(path) else null
val ext = file?.extension?.lowercase(Locale.getDefault()) ?: ""
val isAudio = FileTypeUtils.AUDIO_EXTENSIONS.contains(ext) ||
(mimeCol >= 0 && cursor.getString(mimeCol)?.startsWith("audio/") == true)

if (!isAudio) continue

val id = if (idCol >= 0) cursor.getLong(idCol) else 0L
val title = if (titleCol >= 0) cursor.getString(titleCol) ?: file?.nameWithoutExtension ?: "Unknown Track" else "Unknown Track"
val displayName = if (nameCol >= 0) cursor.getString(nameCol) ?: file?.name ?: title else title
val artist = if (artistCol >= 0) cursor.getString(artistCol)?.takeIf { it != "<unknown>" } ?: "Unknown Artist" else "Unknown Artist"
val artistId = if (artistIdCol >= 0) cursor.getLong(artistIdCol) else 0L
val album = if (albumCol >= 0) cursor.getString(albumCol)?.takeIf { it != "<unknown>" } ?: "Unknown Album" else "Unknown Album"
val albumId = if (albumIdCol >= 0) cursor.getLong(albumIdCol) else 0L
val size = if (sizeCol >= 0) cursor.getLong(sizeCol) else file?.length() ?: 0L
val duration = if (durCol >= 0) cursor.getLong(durCol) else 0L
val dateModified = if (modCol >= 0) cursor.getLong(modCol) else 0L
val dateAdded = if (addCol >= 0) cursor.getLong(addCol) else 0L
val mimeType = if (mimeCol >= 0) cursor.getString(mimeCol) ?: FileTypeUtils.getAudioMimeTypeFromExtension(ext) else FileTypeUtils.getAudioMimeTypeFromExtension(ext)
val trackNumber = if (trackCol >= 0) cursor.getInt(trackCol) else 0
val year = if (yearCol >= 0) cursor.getInt(yearCol) else 0

val uri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)
val albumArtUri = if (albumId > 0) ContentUris.withAppendedId(ALBUM_ART_BASE_URI, albumId) else null

tracks.add(
LocalAudioTrack(
id = id,
title = title,
displayName = displayName,
artist = artist,
artistId = artistId,
album = album,
albumId = albumId,
albumKey = LocalAlbum.createId(album, artist),
trackNumber = trackNumber,
year = year,
duration = duration,
durationFormatted = VideoScanUtils.formatDuration(duration),
size = size,
sizeFormatted = VideoScanUtils.formatFileSize(size),
path = path,
uri = uri,
albumArtUri = albumArtUri,
mimeType = mimeType,
dateAdded = dateAdded,
dateModified = dateModified,
)
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error scanning MediaStore for audio", e)
}

// Group into albums (ID from album + artist, never from title alone)
val albumsMap = mutableMapOf<String, MutableList<LocalAudioTrack>>()
for (track in tracks) {
val albumKey = track.albumKey
albumsMap.getOrPut(albumKey) { mutableListOf() }.add(track)
}

val albums = albumsMap.map { (key, albumTracks) ->
val first = albumTracks.first()
val sortedTracks = albumTracks.sortedWith(
compareBy<LocalAudioTrack> { if (it.trackNumber > 0) it.trackNumber else Int.MAX_VALUE }
.thenBy { it.title.lowercase(Locale.getDefault()) }
)
val totalDuration = albumTracks.sumOf { it.duration }
val artUri = albumTracks.firstOrNull { it.albumArtUri != null }?.albumArtUri
val maxYear = albumTracks.maxOfOrNull { it.year } ?: 0

LocalAlbum(
id = key,
title = first.album,
artist = first.artist,
trackCount = albumTracks.size,
duration = totalDuration,
durationFormatted = VideoScanUtils.formatDuration(totalDuration),
year = maxYear,
artworkUri = artUri,
tracks = sortedTracks,
)
}.sortedBy { it.title.lowercase(Locale.getDefault()) }

// Group into artists
val artistsMap = mutableMapOf<String, MutableList<LocalAlbum>>()
for (album in albums) {
val artistKey = LocalAudioArtist.createId(album.artist)
artistsMap.getOrPut(artistKey) { mutableListOf() }.add(album)
}

val artists = artistsMap.map { (key, artistAlbums) ->
val artistName = artistAlbums.first().artist
val artistTracks = artistAlbums.flatMap { it.tracks }
LocalAudioArtist(
id = key,
name = artistName,
trackCount = artistTracks.size,
albumCount = artistAlbums.size,
albums = artistAlbums,
tracks = artistTracks,
)
}.sortedBy { it.name.lowercase(Locale.getDefault()) }

AudioLibraryData(
tracks = tracks.sortedBy { it.title.lowercase(Locale.getDefault()) },
albums = albums,
artists = artists,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package app.aryan447.mpvium.domain.media.model

import android.net.Uri
import androidx.compose.runtime.Immutable
import java.util.Locale

@Immutable
data class LocalAlbum(
val id: String,
val title: String,
val artist: String,
val trackCount: Int = 0,
val duration: Long = 0L,
val durationFormatted: String = "",
val year: Int = 0,
val artworkUri: Uri? = null,
val tracks: List<LocalAudioTrack> = emptyList(),
) {
companion object {
/**
* Generates a unique, stable album identifier from album name and artist name.
* Never from title alone (per spec 14 requirement 2).
*/
fun createId(album: String, artist: String): String {
val normArtist = artist.trim().lowercase(Locale.ROOT)
val normAlbum = album.trim().lowercase(Locale.ROOT)
return "$normArtist::$normAlbum"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package app.aryan447.mpvium.domain.media.model

import androidx.compose.runtime.Immutable
import java.util.Locale

@Immutable
data class LocalAudioArtist(
val id: String,
val name: String,
val trackCount: Int = 0,
val albumCount: Int = 0,
val albums: List<LocalAlbum> = emptyList(),
val tracks: List<LocalAudioTrack> = emptyList(),
) {
companion object {
fun createId(name: String): String = name.trim().lowercase(Locale.ROOT)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package app.aryan447.mpvium.domain.media.model

import android.net.Uri
import androidx.compose.runtime.Immutable

@Immutable
data class LocalAudioTrack(
val id: Long,
val title: String,
val displayName: String,
val artist: String,
val artistId: Long = 0L,
val album: String,
val albumId: Long = 0L,
val albumKey: String = LocalAlbum.createId(album, artist),
val trackNumber: Int = 0,
val year: Int = 0,
val duration: Long = 0L,
val durationFormatted: String = "",
val size: Long = 0L,
val sizeFormatted: String = "",
val path: String = "",
val uri: Uri,
val albumArtUri: Uri? = null,
val mimeType: String = "audio/*",
val dateAdded: Long = 0L,
val dateModified: Long = 0L,
)
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class BrowserPreferences(
val layoutShows = preferenceStore.getEnum("layout_shows", MediaLayoutMode.GRID)
val layoutMovies = preferenceStore.getEnum("layout_movies", MediaLayoutMode.GRID)
val layoutFolders = preferenceStore.getEnum("layout_folders", MediaLayoutMode.LIST)
val layoutAudio = preferenceStore.getEnum("layout_audio", MediaLayoutMode.GRID)
val mediaLayoutMode = layoutFolders

// Visibility preferences for folder card chips
Expand Down
Loading
Loading