From e7581bbc5f4ee0206cffea8b5d1d86b4c7f6e7d3 Mon Sep 17 00:00:00 2001 From: aryan447 Date: Sat, 7 Nov 2026 12:37:13 +0530 Subject: [PATCH 1/3] feat(library): add Audio tab with MediaStore scanning and queue playback Add dedicated Audio tab with MediaStore scanning, album queues, and background playback. Scan audio albums, artists, tracks, and album art URIs without polluting video folders or tabs; provide browsing across albums, all tracks, and artists; render an audio-mode player surface without video gestures; and integrate album queue navigation with MediaPlaybackService. Verified - git diff --check clean - InitOrderChecker verified 0 violations in AudioViewModel and PlayerViewModel - Added unit tests in AudioModelsTest, MainTabsTest, and BrowserPreferencesLayoutTest --- CHANGELOG.md | 4 + app/src/main/AndroidManifest.xml | 1 + .../app/aryan447/mpvium/di/DomainModule.kt | 2 + .../mpvium/domain/audio/AudioRepository.kt | 41 ++ .../mpvium/domain/audio/AudioScanUtils.kt | 212 +++++++ .../mpvium/domain/media/model/LocalAlbum.kt | 30 + .../domain/media/model/LocalAudioArtist.kt | 18 + .../domain/media/model/LocalAudioTrack.kt | 28 + .../mpvium/preferences/BrowserPreferences.kt | 1 + .../mpvium/ui/audio/AlbumDetailScreen.kt | 248 +++++++++ .../mpvium/ui/audio/AudioPlayerLauncher.kt | 53 ++ .../aryan447/mpvium/ui/audio/AudioScreen.kt | 323 +++++++++++ .../mpvium/ui/audio/AudioViewModel.kt | 128 +++++ .../ui/audio/components/AlbumArtImage.kt | 151 +++++ .../mpvium/ui/audio/components/AudioCards.kt | 334 +++++++++++ .../aryan447/mpvium/ui/browser/MainScreen.kt | 12 +- .../aryan447/mpvium/ui/browser/MainTabs.kt | 12 +- .../mpvium/ui/player/MediaPlaybackService.kt | 42 +- .../mpvium/ui/player/PlayerActivity.kt | 52 +- .../mpvium/ui/player/PlayerViewModel.kt | 30 + .../ui/player/audio/AudioPlayerSurface.kt | 519 ++++++++++++++++++ .../ui/player/controls/PlayerControls.kt | 10 + .../AppearancePreferencesScreen.kt | 2 + .../mpvium/utils/storage/VideoScanUtils.kt | 42 +- app/src/main/res/values/strings.xml | 21 + .../mpvium/domain/audio/AudioModelsTest.kt | 74 +++ .../BrowserPreferencesLayoutTest.kt | 7 + .../mpvium/ui/browser/MainTabsTest.kt | 44 +- 28 files changed, 2406 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/app/aryan447/mpvium/domain/audio/AudioRepository.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/domain/audio/AudioScanUtils.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAlbum.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioArtist.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioTrack.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/audio/AlbumDetailScreen.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/audio/AudioPlayerLauncher.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/audio/AudioViewModel.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/audio/components/AlbumArtImage.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/audio/components/AudioCards.kt create mode 100644 app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt create mode 100644 app/src/test/java/app/aryan447/mpvium/domain/audio/AudioModelsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4a2efb..96f7d132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`), 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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ffa92f0b..efdd597d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + = _libraryData.asStateFlow() + + private val _hasAudio = MutableStateFlow(false) + val hasAudio: StateFlow = _hasAudio.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _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 +} diff --git a/app/src/main/java/app/aryan447/mpvium/domain/audio/AudioScanUtils.kt b/app/src/main/java/app/aryan447/mpvium/domain/audio/AudioScanUtils.kt new file mode 100644 index 00000000..0d988277 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/domain/audio/AudioScanUtils.kt @@ -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 = emptyList(), + val albums: List = emptyList(), + val artists: List = 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() + + 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 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 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>() + 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 { 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>() + 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, + ) + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAlbum.kt b/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAlbum.kt new file mode 100644 index 00000000..520b7024 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAlbum.kt @@ -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 = 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" + } + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioArtist.kt b/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioArtist.kt new file mode 100644 index 00000000..cf3e9f46 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioArtist.kt @@ -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 = emptyList(), + val tracks: List = emptyList(), +) { + companion object { + fun createId(name: String): String = name.trim().lowercase(Locale.ROOT) + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioTrack.kt b/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioTrack.kt new file mode 100644 index 00000000..52991956 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/domain/media/model/LocalAudioTrack.kt @@ -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, +) diff --git a/app/src/main/java/app/aryan447/mpvium/preferences/BrowserPreferences.kt b/app/src/main/java/app/aryan447/mpvium/preferences/BrowserPreferences.kt index 05b6068b..f252ad48 100644 --- a/app/src/main/java/app/aryan447/mpvium/preferences/BrowserPreferences.kt +++ b/app/src/main/java/app/aryan447/mpvium/preferences/BrowserPreferences.kt @@ -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 diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/AlbumDetailScreen.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/AlbumDetailScreen.kt new file mode 100644 index 00000000..7bac493b --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/AlbumDetailScreen.kt @@ -0,0 +1,248 @@ +package app.aryan447.mpvium.ui.audio + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.aryan447.mpvium.R +import app.aryan447.mpvium.domain.audio.AudioRepository +import app.aryan447.mpvium.presentation.Screen +import app.aryan447.mpvium.ui.audio.components.AlbumArtImage +import app.aryan447.mpvium.ui.audio.components.TrackRow +import app.aryan447.mpvium.ui.theme.LocalGlass +import app.aryan447.mpvium.ui.theme.glassRimBrush +import app.aryan447.mpvium.ui.utils.LocalBackStack +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject + +@Serializable +data class AlbumDetailScreen( + val albumId: String, + val albumTitle: String, +) : Screen { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val context = LocalContext.current + val backstack = LocalBackStack.current + val haptic = LocalHapticFeedback.current + val audioRepository = koinInject() + val libraryData by audioRepository.libraryData.collectAsState() + val isGlass = LocalGlass.current + val dark = isSystemInDarkTheme() + + val album = libraryData.albums.firstOrNull { it.id == albumId } + + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { + Text( + text = album?.title ?: albumTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + backstack.removeLastOrNull() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) + }, + ) { padding -> + if (album == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.audio_empty_no_audio_title), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@Scaffold + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(130.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .then( + if (isGlass) { + Modifier.border(1.dp, glassRimBrush(dark), RoundedCornerShape(16.dp)) + } else { + Modifier + } + ), + ) { + AlbumArtImage( + artworkUri = album.artworkUri, + contentDescription = album.title, + modifier = Modifier.fillMaxSize(), + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = album.title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = album.artist, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + val meta = buildString { + if (album.year > 0) append("${album.year} • ") + append(context.getString(R.string.audio_track_count, album.trackCount)) + if (album.durationFormatted.isNotBlank()) append(" • ${album.durationFormatted}") + } + Text( + text = meta, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } + } + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + AudioPlayerLauncher.playAlbum(context, album, 0) + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(32.dp), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.audio_play_all)) + } + + FilledTonalButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + val shuffledIndices = album.tracks.indices.shuffled() + val start = shuffledIndices.firstOrNull() ?: 0 + AudioPlayerLauncher.playAlbum(context, album, start) + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(32.dp), + ) { + Icon(Icons.Filled.Shuffle, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.audio_shuffle)) + } + } + } + + itemsIndexed(album.tracks, key = { _, track -> track.id }) { index, track -> + TrackRow( + track = track, + trackNumber = if (track.trackNumber > 0) track.trackNumber else index + 1, + showArtwork = false, + onClick = { + AudioPlayerLauncher.playAlbum(context, album, index) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioPlayerLauncher.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioPlayerLauncher.kt new file mode 100644 index 00000000..f111ced9 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioPlayerLauncher.kt @@ -0,0 +1,53 @@ +package app.aryan447.mpvium.ui.audio + +import android.content.Context +import android.content.Intent +import android.net.Uri +import app.aryan447.mpvium.domain.media.model.LocalAlbum +import app.aryan447.mpvium.domain.media.model.LocalAudioTrack +import app.aryan447.mpvium.ui.player.PlayerActivity + +object AudioPlayerLauncher { + + /** + * Launches PlayerActivity in audio mode with the album tracks as the playback queue. + */ + fun playAlbum(context: Context, album: LocalAlbum, startIndex: Int = 0) { + if (album.tracks.isEmpty()) return + val index = startIndex.coerceIn(album.tracks.indices) + val selectedTrack = album.tracks[index] + val playlistUris = ArrayList(album.tracks.map { it.uri }) + + val intent = Intent(context, PlayerActivity::class.java).apply { + data = selectedTrack.uri + putParcelableArrayListExtra("playlist", playlistUris) + putExtra("playlist_index", index) + putExtra("is_audio_mode", true) + putExtra("media_title", selectedTrack.title) + putExtra("media_artist", selectedTrack.artist) + putExtra("album_title", album.title) + album.artworkUri?.let { putExtra("album_art_uri", it.toString()) } + } + context.startActivity(intent) + } + + /** + * Launches PlayerActivity for a track or list of tracks. + */ + fun playTrack(context: Context, track: LocalAudioTrack, queue: List = listOf(track)) { + val index = queue.indexOf(track).coerceAtLeast(0) + val playlistUris = ArrayList(queue.map { it.uri }) + + val intent = Intent(context, PlayerActivity::class.java).apply { + data = track.uri + putParcelableArrayListExtra("playlist", playlistUris) + putExtra("playlist_index", index) + putExtra("is_audio_mode", true) + putExtra("media_title", track.title) + putExtra("media_artist", track.artist) + putExtra("album_title", track.album) + track.albumArtUri?.let { putExtra("album_art_uri", it.toString()) } + } + context.startActivity(intent) + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt new file mode 100644 index 00000000..de535553 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt @@ -0,0 +1,323 @@ +package app.aryan447.mpvium.ui.audio + +import android.app.Application +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import app.aryan447.mpvium.R +import app.aryan447.mpvium.preferences.BrowserPreferences +import app.aryan447.mpvium.preferences.MediaLayoutMode +import app.aryan447.mpvium.preferences.preference.collectAsState +import app.aryan447.mpvium.presentation.Screen +import app.aryan447.mpvium.presentation.components.ExpressiveChoiceRow +import app.aryan447.mpvium.presentation.components.pullrefresh.PullRefreshBox +import app.aryan447.mpvium.ui.audio.components.AlbumCard +import app.aryan447.mpvium.ui.audio.components.AlbumRow +import app.aryan447.mpvium.ui.audio.components.ArtistRow +import app.aryan447.mpvium.ui.audio.components.TrackRow +import app.aryan447.mpvium.ui.browser.LocalNavigationBarHeight +import app.aryan447.mpvium.ui.browser.components.MediaLayoutToggle +import app.aryan447.mpvium.ui.browser.states.EmptyState +import app.aryan447.mpvium.ui.browser.states.LoadingState +import app.aryan447.mpvium.ui.utils.LocalBackStack +import kotlinx.serialization.Serializable +import org.koin.compose.koinInject + +@Serializable +object AudioScreen : Screen { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val context = LocalContext.current + val backstack = LocalBackStack.current + val haptic = LocalHapticFeedback.current + val browserPreferences = koinInject() + val layoutAudio by browserPreferences.layoutAudio.collectAsState() + val navBarHeight = LocalNavigationBarHeight.current + + val vm: AudioViewModel = viewModel( + factory = AudioViewModel.factory(context.applicationContext as Application) + ) + + val filter by vm.filter.collectAsState() + val searchQuery by vm.searchQuery.collectAsState() + val isSearching by vm.isSearching.collectAsState() + val isLoading by vm.isLoading.collectAsState() + val albums by vm.filteredAlbums.collectAsState() + val tracks by vm.filteredTracks.collectAsState() + val artists by vm.filteredArtists.collectAsState() + val libraryData by vm.libraryData.collectAsState() + + BackHandler(enabled = isSearching) { + vm.setSearching(false) + } + + Scaffold( + containerColor = Color.Transparent, + topBar = { + if (isSearching) { + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = searchQuery, + onQueryChange = vm::setSearchQuery, + onSearch = {}, + expanded = true, + onExpandedChange = {}, + placeholder = { Text(stringResource(R.string.audio_search_placeholder)) }, + leadingIcon = { + IconButton(onClick = { vm.setSearching(false) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { vm.setSearchQuery("") }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.close), + ) + } + } + }, + ) + }, + expanded = true, + onExpandedChange = { if (!it) vm.setSearching(false) }, + modifier = Modifier.fillMaxWidth(), + colors = SearchBarDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + // Search results displayed in the body below + } + } else { + TopAppBar( + title = { + Text( + text = stringResource(R.string.tab_audio), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + }, + actions = { + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + vm.setSearching(true) + }) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.search), + ) + } + + if (filter == AudioTabFilter.ALBUMS) { + MediaLayoutToggle( + mode = layoutAudio, + onToggle = { + val next = if (layoutAudio == MediaLayoutMode.GRID) { + MediaLayoutMode.LIST + } else { + MediaLayoutMode.GRID + } + browserPreferences.layoutAudio.set(next) + }, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + ) + } + }, + ) { padding -> + PullRefreshBox( + refreshing = isLoading, + onRefresh = { vm.refresh() }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Browsing Tabs: Albums / All tracks / Artists + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + ExpressiveChoiceRow( + options = AudioTabFilter.entries, + selected = filter, + onSelect = { vm.setFilter(it) }, + labelOf = { + when (it) { + AudioTabFilter.ALBUMS -> stringResource(R.string.audio_filter_albums) + AudioTabFilter.TRACKS -> stringResource(R.string.audio_filter_tracks) + AudioTabFilter.ARTISTS -> stringResource(R.string.audio_filter_artists) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (isLoading && libraryData.tracks.isEmpty()) { + LoadingState(modifier = Modifier.fillMaxSize()) + } else if (libraryData.tracks.isEmpty()) { + EmptyState( + title = stringResource(R.string.audio_empty_no_audio_title), + message = stringResource(R.string.audio_empty_no_audio_message), + modifier = Modifier + .fillMaxSize() + .padding(bottom = navBarHeight), + ) + } else { + val bottomPadding = navBarHeight + 16.dp + + when (filter) { + AudioTabFilter.ALBUMS -> { + if (albums.isEmpty()) { + EmptyState( + title = stringResource(R.string.audio_empty_search_title), + message = stringResource(R.string.audio_empty_search_message, searchQuery), + modifier = Modifier.fillMaxSize(), + ) + } else if (layoutAudio == MediaLayoutMode.GRID) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = bottomPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(albums, key = { it.id }) { album -> + AlbumCard( + album = album, + onClick = { + backstack.add(AlbumDetailScreen(album.id, album.title)) + }, + ) + } + } + } else { + LazyColumn( + contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(albums, key = { it.id }) { album -> + AlbumRow( + album = album, + onClick = { + backstack.add(AlbumDetailScreen(album.id, album.title)) + }, + ) + } + } + } + } + + AudioTabFilter.TRACKS -> { + if (tracks.isEmpty()) { + EmptyState( + title = stringResource(R.string.audio_empty_search_title), + message = stringResource(R.string.audio_empty_search_message, searchQuery), + modifier = Modifier.fillMaxSize(), + ) + } else { + LazyColumn( + contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(tracks, key = { it.id }) { track -> + TrackRow( + track = track, + onClick = { + AudioPlayerLauncher.playTrack(context, track, tracks) + }, + ) + } + } + } + } + + AudioTabFilter.ARTISTS -> { + if (artists.isEmpty()) { + EmptyState( + title = stringResource(R.string.audio_empty_search_title), + message = stringResource(R.string.audio_empty_search_message, searchQuery), + modifier = Modifier.fillMaxSize(), + ) + } else { + LazyColumn( + contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = bottomPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(artists, key = { it.id }) { artist -> + ArtistRow( + artist = artist, + onClick = { + if (artist.albums.size == 1) { + val album = artist.albums.first() + backstack.add(AlbumDetailScreen(album.id, album.title)) + } else { + // Filter by artist name + vm.setSearchQuery(artist.name) + vm.setFilter(AudioTabFilter.ALBUMS) + } + }, + ) + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioViewModel.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioViewModel.kt new file mode 100644 index 00000000..a4ffbbbe --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioViewModel.kt @@ -0,0 +1,128 @@ +package app.aryan447.mpvium.ui.audio + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import app.aryan447.mpvium.domain.audio.AudioLibraryData +import app.aryan447.mpvium.domain.audio.AudioRepository +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 kotlinx.coroutines.Dispatchers +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 org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.Locale + +enum class AudioTabFilter { + ALBUMS, + TRACKS, + ARTISTS, + ; +} + +class AudioViewModel( + application: Application, +) : AndroidViewModel(application), KoinComponent { + + private val audioRepository: AudioRepository by inject() + + private val _filter = MutableStateFlow(AudioTabFilter.ALBUMS) + val filter: StateFlow = _filter.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + val libraryData: StateFlow = audioRepository.libraryData + val isLoading: StateFlow = audioRepository.isLoading + + val filteredAlbums: StateFlow> = combine( + libraryData, + _searchQuery, + ) { data, query -> + val clean = query.trim().lowercase(Locale.getDefault()) + if (clean.isBlank()) { + data.albums + } else { + data.albums.filter { + it.title.lowercase(Locale.getDefault()).contains(clean) || + it.artist.lowercase(Locale.getDefault()).contains(clean) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val filteredTracks: StateFlow> = combine( + libraryData, + _searchQuery, + ) { data, query -> + val clean = query.trim().lowercase(Locale.getDefault()) + if (clean.isBlank()) { + data.tracks + } else { + data.tracks.filter { + it.title.lowercase(Locale.getDefault()).contains(clean) || + it.artist.lowercase(Locale.getDefault()).contains(clean) || + it.album.lowercase(Locale.getDefault()).contains(clean) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val filteredArtists: StateFlow> = combine( + libraryData, + _searchQuery, + ) { data, query -> + val clean = query.trim().lowercase(Locale.getDefault()) + if (clean.isBlank()) { + data.artists + } else { + data.artists.filter { + it.name.lowercase(Locale.getDefault()).contains(clean) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + init { + refresh() + } + + fun setFilter(tab: AudioTabFilter) { + _filter.value = tab + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun setSearching(searching: Boolean) { + _isSearching.value = searching + if (!searching) { + _searchQuery.value = "" + } + } + + fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + audioRepository.refresh() + } + } + + companion object { + fun factory(application: Application): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + AudioViewModel(application) as T + } + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/components/AlbumArtImage.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/components/AlbumArtImage.kt new file mode 100644 index 00000000..f60e1929 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/components/AlbumArtImage.kt @@ -0,0 +1,151 @@ +package app.aryan447.mpvium.ui.audio.components + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.util.LruCache +import android.util.Size +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private val albumArtCache = LruCache(64) + +@Composable +fun AlbumArtImage( + artworkUri: Uri?, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Crop, + targetSizePx: Int = 300, +) { + val context = LocalContext.current + val cacheKey = artworkUri?.toString() ?: "" + + var bitmap by remember(cacheKey) { + mutableStateOf(if (cacheKey.isNotEmpty()) albumArtCache.get(cacheKey) else null) + } + + LaunchedEffect(cacheKey) { + if (cacheKey.isEmpty() || artworkUri == null) { + bitmap = null + return@LaunchedEffect + } + + val cached = albumArtCache.get(cacheKey) + if (cached != null) { + bitmap = cached + return@LaunchedEffect + } + + val loaded = withContext(Dispatchers.IO) { + loadAlbumArtBitmap(context, artworkUri, targetSizePx) + } + if (loaded != null) { + albumArtCache.put(cacheKey, loaded) + bitmap = loaded + } + } + + Crossfade( + targetState = bitmap, + animationSpec = tween(250), + label = "AlbumArtCrossfade", + modifier = modifier, + ) { currentBitmap -> + if (currentBitmap != null) { + Image( + bitmap = currentBitmap.asImageBitmap(), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Audiotrack, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(36.dp), + ) + } + } + } +} + +private fun loadAlbumArtBitmap(context: Context, uri: Uri, targetSizePx: Int): Bitmap? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + context.contentResolver.loadThumbnail(uri, Size(targetSizePx, targetSizePx), null) + } catch (_: Exception) { + loadWithBitmapFactory(context, uri, targetSizePx) + } + } else { + loadWithBitmapFactory(context, uri, targetSizePx) + } + } catch (_: Exception) { + null + } +} + +private fun loadWithBitmapFactory(context: Context, uri: Uri, targetSizePx: Int): Bitmap? { + return try { + context.contentResolver.openInputStream(uri)?.use { stream -> + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(stream, null, options) + + val originalWidth = options.outWidth + val originalHeight = options.outHeight + if (originalWidth <= 0 || originalHeight <= 0) return null + + var sampleSize = 1 + while (originalWidth / (sampleSize * 2) >= targetSizePx && + originalHeight / (sampleSize * 2) >= targetSizePx + ) { + sampleSize *= 2 + } + + val decodeOptions = BitmapFactory.Options().apply { + inSampleSize = sampleSize + } + + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeOptions) + } + } + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/components/AudioCards.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/components/AudioCards.kt new file mode 100644 index 00000000..f724f51a --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/components/AudioCards.kt @@ -0,0 +1,334 @@ +package app.aryan447.mpvium.ui.audio.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.aryan447.mpvium.R +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.ui.theme.LocalGlass +import app.aryan447.mpvium.ui.theme.glassRimBrush + +@Composable +fun AlbumCard( + album: LocalAlbum, + onClick: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp? = null, +) { + val haptic = LocalHapticFeedback.current + val isGlass = LocalGlass.current + val dark = isSystemInDarkTheme() + + Column( + modifier = modifier + .then(if (cardWidth != null) Modifier.width(cardWidth) else Modifier.fillMaxWidth()) + .clip(RoundedCornerShape(14.dp)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + .padding(4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .then( + if (isGlass) { + Modifier.border(1.dp, glassRimBrush(dark), RoundedCornerShape(14.dp)) + } else { + Modifier + } + ), + ) { + AlbumArtImage( + artworkUri = album.artworkUri, + contentDescription = album.title, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = album.title, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = album.artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = stringResource(R.string.audio_track_count, album.trackCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +fun AlbumRow( + album: LocalAlbum, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + val isGlass = LocalGlass.current + val dark = isSystemInDarkTheme() + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .then( + if (isGlass) { + Modifier.border(1.dp, glassRimBrush(dark), RoundedCornerShape(12.dp)) + } else { + Modifier + } + ), + ) { + AlbumArtImage( + artworkUri = album.artworkUri, + contentDescription = album.title, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = album.title, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "${album.artist} • ${stringResource(R.string.audio_track_count, album.trackCount)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (album.durationFormatted.isNotBlank()) { + Text( + text = album.durationFormatted, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + modifier = Modifier.padding(start = 8.dp), + ) + } + } +} + +@Composable +fun TrackRow( + track: LocalAudioTrack, + onClick: () -> Unit, + modifier: Modifier = Modifier, + trackNumber: Int? = null, + showArtwork: Boolean = true, +) { + val haptic = LocalHapticFeedback.current + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showArtwork) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + AlbumArtImage( + artworkUri = track.albumArtUri, + contentDescription = track.title, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(modifier = Modifier.width(12.dp)) + } else if (trackNumber != null) { + Text( + text = if (trackNumber > 0) trackNumber.toString() else "•", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(28.dp), + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = track.title, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = if (track.artist.isNotBlank() && track.album.isNotBlank()) { + "${track.artist} — ${track.album}" + } else { + track.artist.ifBlank { track.album } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = track.durationFormatted, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@Composable +fun ArtistRow( + artist: LocalAudioArtist, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(48.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp), + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = artist.name, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + val albumText = stringResource(R.string.audio_album_count, artist.albumCount) + val trackText = stringResource(R.string.audio_track_count, artist.trackCount) + Text( + text = "$albumText • $trackText", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/browser/MainScreen.kt b/app/src/main/java/app/aryan447/mpvium/ui/browser/MainScreen.kt index f6c0c3d8..e7b398a9 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/browser/MainScreen.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/browser/MainScreen.kt @@ -54,9 +54,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import app.aryan447.mpvium.R +import app.aryan447.mpvium.domain.audio.AudioRepository import app.aryan447.mpvium.preferences.AppearancePreferences import app.aryan447.mpvium.preferences.preference.collectAsState import app.aryan447.mpvium.presentation.Screen +import app.aryan447.mpvium.ui.audio.AudioScreen import app.aryan447.mpvium.ui.browser.folderlist.FolderListScreen import app.aryan447.mpvium.ui.streaming.home.StreamingHomeScreen import app.aryan447.mpvium.ui.theme.DarkMode @@ -203,8 +205,13 @@ object MainScreen : Screen { } val glassHaze = rememberGlassHazeState() val glassStyle = glassHazeStyle(isDark = isDark, kind = GlassKind.Bar) - val navItems = remember(enabledBottomTabs) { - MainTab.visibleTabs(enabledBottomTabs) + val audioRepository = koinInject() + val hasAudio by audioRepository.hasAudio.collectAsState() + LaunchedEffect(Unit) { + audioRepository.checkHasAudio() + } + val navItems = remember(enabledBottomTabs, hasAudio) { + MainTab.visibleTabs(enabledBottomTabs, hasAudio) } fun selectTab(index: Int) { @@ -450,6 +457,7 @@ object MainScreen : Screen { 3 -> FolderListScreen.Content() 4 -> MoreLibraryScreen.Content() 5 -> PlaylistScreen.Content() + 6 -> AudioScreen.Content() else -> TitlesScreen().Content() } } diff --git a/app/src/main/java/app/aryan447/mpvium/ui/browser/MainTabs.kt b/app/src/main/java/app/aryan447/mpvium/ui/browser/MainTabs.kt index a74260d5..2f91741f 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/browser/MainTabs.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/browser/MainTabs.kt @@ -2,6 +2,7 @@ package app.aryan447.mpvium.ui.browser import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Movie @@ -21,6 +22,7 @@ import androidx.compose.ui.graphics.vector.ImageVector * - 3: FOLDERS * - 4: LIBRARY * - 5: PLAYLISTS + * - 6: AUDIO (auto-hidden when no audio exists on device) * * By default, unified TITLES is enabled (5 visible tabs). Users can switch * to separate SHOWS and MOVIES under Settings > Appearance > Bottom navigation. @@ -38,6 +40,7 @@ enum class MainTab( FOLDERS("FOLDERS", 3, Icons.Filled.Folder, "Folders"), LIBRARY("LIBRARY", 4, Icons.Filled.VideoLibrary, "Library"), PLAYLISTS("PLAYLISTS", 5, Icons.AutoMirrored.Filled.PlaylistPlay, "Playlists"), + AUDIO("AUDIO", 6, Icons.Filled.Audiotrack, "Audio"), ; companion object { @@ -48,7 +51,7 @@ enum class MainTab( */ const val RESERVED_MOVIES_CANONICAL_INDEX = 2 - val defaultKeys: Set = setOf(HOME.key, TITLES.key, FOLDERS.key, LIBRARY.key, PLAYLISTS.key) + val defaultKeys: Set = setOf(HOME.key, TITLES.key, FOLDERS.key, LIBRARY.key, PLAYLISTS.key, AUDIO.key) fun fromKey(key: String): MainTab? = entries.firstOrNull { it.key == key } @@ -59,17 +62,20 @@ enum class MainTab( 3 -> FOLDERS 4 -> LIBRARY 5 -> PLAYLISTS + 6 -> AUDIO else -> null } /** Visible tabs in canonical order, filtered by enabled keys. Never empty. */ - fun visibleTabs(enabledKeys: Set): List { + fun visibleTabs(enabledKeys: Set, hasAudio: Boolean = true): List { val effectiveKeys = if (TITLES.key in enabledKeys) { enabledKeys - SHOWS.key - MOVIES.key } else { enabledKeys } - val filtered = entries.filter { it.key in effectiveKeys } + val filtered = entries.filter { tab -> + tab.key in effectiveKeys && (tab != AUDIO || hasAudio) + } return filtered.ifEmpty { listOf(HOME, TITLES, FOLDERS, LIBRARY, PLAYLISTS) } } } diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/MediaPlaybackService.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/MediaPlaybackService.kt index 04c22649..40690760 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/player/MediaPlaybackService.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/MediaPlaybackService.kt @@ -35,6 +35,11 @@ class MediaPlaybackService : MediaBrowserServiceCompat(), MPVLib.EventObserver, KoinComponent { + interface PlaybackActionListener { + fun onSkipToNext() + fun onSkipToPrevious() + } + companion object { private const val TAG = "MediaPlaybackService" private const val NOTIFICATION_ID = 1 @@ -43,6 +48,9 @@ class MediaPlaybackService : @Volatile internal var thumbnail: Bitmap? = null + @Volatile + var playbackActionListener: PlaybackActionListener? = null + @Volatile private var isServiceRunning = false @@ -148,6 +156,8 @@ class MediaPlaybackService : } ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(), type) Log.d(TAG, "Foreground service started successfully") + } catch (e: SecurityException) { + Log.w(TAG, "POST_NOTIFICATIONS permission not granted, proceeding without notification", e) } catch (e: Exception) { Log.e(TAG, "Error starting foreground service", e) } @@ -202,20 +212,30 @@ class MediaPlaybackService : override fun onSkipToNext() { Log.d(TAG, "onSkipToNext called") - // Use precise seeking for videos shorter than 2 minutes (120 seconds) or if preference is enabled - val duration = MPVLib.getPropertyInt("duration") ?: 0 - val shouldUsePreciseSeeking = playerPreferences.usePreciseSeeking.get() || duration < 120 - val seekMode = if (shouldUsePreciseSeeking) "relative+exact" else "relative+keyframes" - MPVLib.command("seek", "10", seekMode) + val listener = playbackActionListener + if (listener != null) { + listener.onSkipToNext() + } else { + // Use precise seeking for videos shorter than 2 minutes (120 seconds) or if preference is enabled + val duration = MPVLib.getPropertyInt("duration") ?: 0 + val shouldUsePreciseSeeking = playerPreferences.usePreciseSeeking.get() || duration < 120 + val seekMode = if (shouldUsePreciseSeeking) "relative+exact" else "relative+keyframes" + MPVLib.command("seek", "10", seekMode) + } } override fun onSkipToPrevious() { Log.d(TAG, "onSkipToPrevious called") - // Use precise seeking for videos shorter than 2 minutes (120 seconds) or if preference is enabled - val duration = MPVLib.getPropertyInt("duration") ?: 0 - val shouldUsePreciseSeeking = playerPreferences.usePreciseSeeking.get() || duration < 120 - val seekMode = if (shouldUsePreciseSeeking) "relative+exact" else "relative+keyframes" - MPVLib.command("seek", "-10", seekMode) + val listener = playbackActionListener + if (listener != null) { + listener.onSkipToPrevious() + } else { + // Use precise seeking for videos shorter than 2 minutes (120 seconds) or if preference is enabled + val duration = MPVLib.getPropertyInt("duration") ?: 0 + val shouldUsePreciseSeeking = playerPreferences.usePreciseSeeking.get() || duration < 120 + val seekMode = if (shouldUsePreciseSeeking) "relative+exact" else "relative+keyframes" + MPVLib.command("seek", "-10", seekMode) + } } override fun onSeekTo(pos: Long) { @@ -293,6 +313,8 @@ class MediaPlaybackService : try { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(NOTIFICATION_ID, buildNotification()) + } catch (e: SecurityException) { + Log.w(TAG, "POST_NOTIFICATIONS permission not granted, skipping notification update", e) } catch (e: Exception) { Log.e(TAG, "Error updating notification", e) } diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerActivity.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerActivity.kt index edce16e7..e5956f59 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerActivity.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerActivity.kt @@ -428,6 +428,15 @@ class PlayerActivity : } mediaIdentifier = getMediaIdentifier(intent, fileName) + val isAudioMode = intent.getBooleanExtra("is_audio_mode", false) || + FileTypeUtils.isAudioFileName(fileName) || + (intent.data?.let { FileTypeUtils.isAudioFileName(it.lastPathSegment ?: "") } == true) + val mediaArtist = intent.getStringExtra("media_artist") ?: "" + val albumTitle = intent.getStringExtra("album_title") ?: "" + val albumArtUriString = intent.getStringExtra("album_art_uri") + val albumArtUri = albumArtUriString?.let { runCatching { Uri.parse(it) }.getOrNull() } + viewModel.setAudioMode(isAudioMode, fileName, mediaArtist, albumTitle, albumArtUri) + // Set HTTP headers (including referer) BEFORE playing the file setHttpHeadersFromExtras(intent.extras) @@ -600,6 +609,7 @@ class PlayerActivity : override fun onDestroy() { Log.d(TAG, "PlayerActivity onDestroy") + MediaPlaybackService.playbackActionListener = null runCatching { // OPTIMIZATION: Prevent any further UI updates or callbacks @@ -710,7 +720,8 @@ class PlayerActivity : override fun onPause() { runCatching { val isInPip = isInPictureInPictureMode - val shouldPause = (!audioPreferences.automaticBackgroundPlayback.get() && !isManualBackgroundPlayback) || + val isAudio = viewModel.isAudioMode.value + val shouldPause = (!isAudio && !audioPreferences.automaticBackgroundPlayback.get() && !isManualBackgroundPlayback) || (isUserFinishing && !isManualBackgroundPlayback) // OPTIMIZATION: Stop playback immediately if finishing to reduce cleanup overhead @@ -3005,9 +3016,26 @@ class PlayerActivity : // Ensure notification channel exists MediaPlaybackService.createNotificationChannel(this) + MediaPlaybackService.playbackActionListener = object : MediaPlaybackService.PlaybackActionListener { + override fun onSkipToNext() { runOnUiThread { playNext() } } + override fun onSkipToPrevious() { runOnUiThread { playPrevious() } } + } + // Get media info before starting service - val artist = runCatching { MPVLib.getPropertyString("metadata/artist") }.getOrNull() ?: "" - val thumbnail = runCatching { MPVLib.grabThumbnail(1080) }.getOrNull() + val artist = viewModel.audioArtist.value.ifBlank { + runCatching { MPVLib.getPropertyString("metadata/artist") }.getOrNull() ?: "" + } + val albumArtUri = viewModel.audioArtUri.value + val albumArtBitmap = if (albumArtUri != null) { + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentResolver.loadThumbnail(albumArtUri, android.util.Size(512, 512), null) + } else { + contentResolver.openInputStream(albumArtUri)?.use { android.graphics.BitmapFactory.decodeStream(it) } + } + }.getOrNull() + } else null + val thumbnail = albumArtBitmap ?: runCatching { MPVLib.grabThumbnail(1080) }.getOrNull() // Pass media info via intent extras val intent = Intent(this, MediaPlaybackService::class.java).apply { @@ -3022,6 +3050,9 @@ class PlayerActivity : startForegroundService(intent) bindService(intent, serviceConnection, BIND_AUTO_CREATE) Log.d(TAG, "Service start and bind initiated") + } catch (e: SecurityException) { + Log.w(TAG, "POST_NOTIFICATIONS permission not granted, continuing without foreground notification", e) + runCatching { bindService(intent, serviceConnection, BIND_AUTO_CREATE) } } catch (e: Exception) { Log.e(TAG, "Error starting/binding service", e) } @@ -3281,6 +3312,21 @@ class PlayerActivity : // Generate new media identifier for playback state mediaIdentifier = getMediaIdentifierFromUri(uri, fileName) + if (viewModel.isAudioMode.value) { + viewModel.setAudioMode( + enabled = true, + title = fileName, + artist = viewModel.audioArtist.value, + album = viewModel.audioAlbum.value, + artUri = viewModel.audioArtUri.value, + ) + mediaPlaybackService?.setMediaInfo( + title = fileName, + artist = viewModel.audioArtist.value, + thumbnail = MediaPlaybackService.thumbnail, + ) + } + // Set HTTP headers (including referer) for network streams setHttpHeadersForUri(uri) diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerViewModel.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerViewModel.kt index e1c45d4e..ab7d2b8a 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/PlayerViewModel.kt @@ -189,6 +189,36 @@ class PlayerViewModel( var currentFilePath: String? = null private var lastAutoSelectedMediaTitle: String? = null + // Audio-only mode properties + private val _isAudioMode = MutableStateFlow(false) + val isAudioMode: StateFlow = _isAudioMode.asStateFlow() + + private val _audioTitle = MutableStateFlow("") + val audioTitle: StateFlow = _audioTitle.asStateFlow() + + private val _audioArtist = MutableStateFlow("") + val audioArtist: StateFlow = _audioArtist.asStateFlow() + + private val _audioAlbum = MutableStateFlow("") + val audioAlbum: StateFlow = _audioAlbum.asStateFlow() + + private val _audioArtUri = MutableStateFlow(null) + val audioArtUri: StateFlow = _audioArtUri.asStateFlow() + + fun setAudioMode( + enabled: Boolean, + title: String? = null, + artist: String? = null, + album: String? = null, + artUri: Uri? = null, + ) { + _isAudioMode.value = enabled + title?.let { _audioTitle.value = it } + artist?.let { _audioArtist.value = it } + album?.let { _audioAlbum.value = it } + _audioArtUri.value = artUri + } + // Audio state val currentVolume = MutableStateFlow(host.audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) private val volumeBoostCap by MPVLib.propInt["volume-max"].collectAsState(viewModelScope) diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt new file mode 100644 index 00000000..cf0cb3b2 --- /dev/null +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt @@ -0,0 +1,519 @@ +package app.aryan447.mpvium.ui.player.audio + +import android.net.Uri +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.RepeatOne +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.aryan447.mpvium.R +import app.aryan447.mpvium.preferences.RepeatMode +import app.aryan447.mpvium.ui.audio.components.AlbumArtImage +import app.aryan447.mpvium.ui.player.PlayerActivity +import app.aryan447.mpvium.ui.player.PlayerViewModel +import app.aryan447.mpvium.ui.theme.LocalGlass +import app.aryan447.mpvium.ui.theme.glassRimBrush +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AudioPlayerSurface( + viewModel: PlayerViewModel, + onBackPress: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val isGlass = LocalGlass.current + val dark = isSystemInDarkTheme() + + val title by viewModel.audioTitle.collectAsState() + val artist by viewModel.audioArtist.collectAsState() + val album by viewModel.audioAlbum.collectAsState() + val artworkUri by viewModel.audioArtUri.collectAsState() + + val paused by viewModel.paused.collectAsState() + val isPlaying = paused == false + val pos by viewModel.pos.collectAsState() + val duration by viewModel.duration.collectAsState() + val shuffleEnabled by viewModel.shuffleEnabled.collectAsState() + val repeatMode by viewModel.repeatMode.collectAsState() + + val currentPosSec = pos ?: 0 + val durationSec = duration ?: 0 + + var isUserScrubbing by remember { mutableStateOf(false) } + var scrubPositionSec by remember { mutableFloatStateOf(0f) } + + var showQueueSheet by remember { mutableStateOf(false) } + val queueSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val statusBarPadding = WindowInsets.statusBars.asPaddingValues() + val navBarPadding = WindowInsets.navigationBars.asPaddingValues() + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surface, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f), + MaterialTheme.colorScheme.surface, + ) + ) + ) + .padding( + top = statusBarPadding.calculateTopPadding(), + bottom = navBarPadding.calculateBottomPadding(), + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + // Top bar + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onBackPress() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = stringResource(R.string.audio_now_playing), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + ) + + IconButton(onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showQueueSheet = true + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.QueueMusic, + contentDescription = stringResource(R.string.audio_queue), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Center Album Artwork + Box( + modifier = Modifier + .weight(1f, fill = false) + .fillMaxWidth(0.85f) + .aspectRatio(1f) + .shadow(16.dp, RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .then( + if (isGlass) { + Modifier.border(1.dp, glassRimBrush(dark), RoundedCornerShape(24.dp)) + } else { + Modifier + } + ), + ) { + AlbumArtImage( + artworkUri = artworkUri, + contentDescription = title, + modifier = Modifier.fillMaxSize(), + targetSizePx = 600, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Track Details (Title, Artist, Album) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title.ifBlank { stringResource(R.string.audio_unknown_track) }, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = artist.ifBlank { stringResource(R.string.audio_unknown_artist) }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (album.isNotBlank() && album != title) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = album, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Seek Bar & Timers + Column(modifier = Modifier.fillMaxWidth()) { + val sliderValue = if (isUserScrubbing) { + scrubPositionSec + } else { + currentPosSec.toFloat() + } + val sliderMax = durationSec.coerceAtLeast(1).toFloat() + + Slider( + value = sliderValue.coerceIn(0f, sliderMax), + onValueChange = { + isUserScrubbing = true + scrubPositionSec = it + }, + onValueChangeFinished = { + viewModel.seekTo(scrubPositionSec.toInt()) + isUserScrubbing = false + }, + valueRange = 0f..sliderMax, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val displayPos = if (isUserScrubbing) scrubPositionSec.toInt() else currentPosSec + Text( + text = formatTimecode(displayPos), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatTimecode(durationSec), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Controls Row: Shuffle | Prev | Play/Pause | Next | Repeat + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.toggleShuffle() + }, + ) { + Icon( + imageVector = Icons.Filled.Shuffle, + contentDescription = stringResource(R.string.audio_shuffle), + tint = if (shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + } + + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.playPrevious() + }, + enabled = viewModel.hasPrevious(), + ) { + Icon( + imageVector = Icons.Default.SkipPrevious, + contentDescription = stringResource(R.string.player_previous), + modifier = Modifier.size(36.dp), + tint = if (viewModel.hasPrevious()) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + FilledIconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.togglePause() + }, + shape = CircleShape, + modifier = Modifier.size(68.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) { + stringResource(R.string.player_pause) + } else { + stringResource(R.string.player_play) + }, + modifier = Modifier.size(36.dp), + ) + } + + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.playNext() + }, + enabled = viewModel.hasNext(), + ) { + Icon( + imageVector = Icons.Default.SkipNext, + contentDescription = stringResource(R.string.player_next), + modifier = Modifier.size(36.dp), + tint = if (viewModel.hasNext()) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.cycleRepeatMode() + }, + ) { + val repeatIcon = when (repeatMode) { + RepeatMode.ONE -> Icons.Filled.RepeatOne + else -> Icons.Filled.Repeat + } + val repeatTint = when (repeatMode) { + RepeatMode.OFF -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.primary + } + Icon( + imageVector = repeatIcon, + contentDescription = stringResource(R.string.player_repeat), + tint = repeatTint, + ) + } + } + } + } + + // Queue Bottom Sheet + if (showQueueSheet) { + val playerActivity = context as? PlayerActivity + val playlist = playerActivity?.playlist ?: emptyList() + val currentIndex = playerActivity?.playlistIndex ?: 0 + + ModalBottomSheet( + onDismissRequest = { showQueueSheet = false }, + sheetState = queueSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.audio_queue), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp), + ) + + if (playlist.isEmpty()) { + Text( + text = stringResource(R.string.audio_empty_no_audio_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(360.dp), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + itemsIndexed(playlist) { index, uri -> + val isSelected = index == currentIndex + val itemTitle = playerActivity.getPlaylistItemTitle(uri) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) + } else { + Color.Transparent + } + ) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + playerActivity.loadPlaylistItem(index) + showQueueSheet = false + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.width(32.dp), + ) + + Text( + text = itemTitle, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ), + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } + } + } +} + +private fun formatTimecode(seconds: Int): String { + val s = seconds.coerceAtLeast(0) + val m = s / 60 + val remS = s % 60 + val h = m / 60 + val remM = m % 60 + return if (h > 0) { + String.format(Locale.US, "%d:%02d:%02d", h, remM, remS) + } else { + String.format(Locale.US, "%d:%02d", remM, remS) + } +} diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/controls/PlayerControls.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/controls/PlayerControls.kt index 3ca6f1ac..6ea28556 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/controls/PlayerControls.kt @@ -186,6 +186,16 @@ fun PlayerControls( val forwardCacheDuration by viewModel.forwardCacheDuration.collectAsState() val interactionSource = remember { MutableInteractionSource() } val controlsShown by viewModel.controlsShown.collectAsState() + val isAudioMode by viewModel.isAudioMode.collectAsState() + if (isAudioMode) { + app.aryan447.mpvium.ui.player.audio.AudioPlayerSurface( + viewModel = viewModel, + onBackPress = onBackPress, + modifier = modifier, + ) + return + } + val areControlsLocked by viewModel.areControlsLocked.collectAsState() val seekBarShown by viewModel.seekBarShown.collectAsState() val pausedForCache by MPVLib.propBoolean["paused-for-cache"].collectAsState() diff --git a/app/src/main/java/app/aryan447/mpvium/ui/preferences/AppearancePreferencesScreen.kt b/app/src/main/java/app/aryan447/mpvium/ui/preferences/AppearancePreferencesScreen.kt index 7cf1410f..da8e2583 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/preferences/AppearancePreferencesScreen.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/preferences/AppearancePreferencesScreen.kt @@ -94,6 +94,7 @@ object AppearancePreferencesScreen : Screen { MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS, + MainTab.AUDIO, ) } else { listOf( @@ -103,6 +104,7 @@ object AppearancePreferencesScreen : Screen { MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS, + MainTab.AUDIO, ) } } diff --git a/app/src/main/java/app/aryan447/mpvium/utils/storage/VideoScanUtils.kt b/app/src/main/java/app/aryan447/mpvium/utils/storage/VideoScanUtils.kt index a3bc0dc2..7c4005ac 100644 --- a/app/src/main/java/app/aryan447/mpvium/utils/storage/VideoScanUtils.kt +++ b/app/src/main/java/app/aryan447/mpvium/utils/storage/VideoScanUtils.kt @@ -282,7 +282,7 @@ object VideoScanUtils { // Formatting utilities - private fun formatDuration(durationMs: Long): String { + fun formatDuration(durationMs: Long): String { if (durationMs <= 0) return "0s" val seconds = durationMs / 1000 @@ -297,7 +297,7 @@ object VideoScanUtils { } } - private fun formatFileSize(bytes: Long): String { + fun formatFileSize(bytes: Long): String { if (bytes <= 0) return "0 B" val units = arrayOf("B", "KB", "MB", "GB", "TB") val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt() @@ -347,6 +347,44 @@ object FileTypeUtils { return VIDEO_EXTENSIONS.contains(extension) } + // Audio file extensions (separate from VIDEO_EXTENSIONS to prevent polluting video tabs/folders) + val AUDIO_EXTENSIONS = setOf( + "mp3", "flac", "wav", "m4a", "aac", "ogg", "oga", "opus", "wma", "alac", "aiff", "ape" + ) + + /** + * Checks if a file is an audio file based on extension + */ + fun isAudioFile(file: File): Boolean { + val extension = file.extension.lowercase(Locale.getDefault()) + return AUDIO_EXTENSIONS.contains(extension) + } + + /** + * Checks if a filename has an audio extension + */ + fun isAudioFileName(name: String): Boolean { + val extension = name.substringAfterLast('.', "").lowercase(Locale.getDefault()) + return AUDIO_EXTENSIONS.contains(extension) + } + + /** + * Gets MIME type from audio file extension + */ + fun getAudioMimeTypeFromExtension(extension: String): String = + when (extension.lowercase(Locale.getDefault())) { + "mp3" -> "audio/mpeg" + "flac" -> "audio/flac" + "wav" -> "audio/wav" + "m4a", "alac" -> "audio/mp4" + "aac" -> "audio/aac" + "ogg", "oga", "opus" -> "audio/ogg" + "wma" -> "audio/x-ms-wma" + "aiff" -> "audio/x-aiff" + "ape" -> "audio/x-ape" + else -> "audio/*" + } + /** * Gets MIME type from file extension */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1008ab02..4c98e7e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -746,4 +746,25 @@ Add to series Create new series Added to \"%1$s\" + + + Audio + Search audio… + Albums + All tracks + Artists + No audio files found + Add songs, albums, or audio tracks to your device storage to see them here + No audio matches found + No audio matches \'%1$s\' + Play all + Shuffle + Queue + Now playing + %1$d tracks + %1$d albums + Unknown Artist + Unknown Album + Unknown Track + Audio tab diff --git a/app/src/test/java/app/aryan447/mpvium/domain/audio/AudioModelsTest.kt b/app/src/test/java/app/aryan447/mpvium/domain/audio/AudioModelsTest.kt new file mode 100644 index 00000000..31cce391 --- /dev/null +++ b/app/src/test/java/app/aryan447/mpvium/domain/audio/AudioModelsTest.kt @@ -0,0 +1,74 @@ +package app.aryan447.mpvium.domain.audio + +import android.net.Uri +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class AudioModelsTest { + + @Test + fun localAlbum_idDerivedFromAlbumAndArtist_neverTitleAlone() { + val albumTitle = "Greatest Hits" + val artistA = "Queen" + val artistB = "ABBA" + + val idA = LocalAlbum.createId(album = albumTitle, artist = artistA) + val idB = LocalAlbum.createId(album = albumTitle, artist = artistB) + + // Even though album title is identical, different artists must produce different IDs + assertNotEquals("Albums with same title by different artists must have distinct IDs", idA, idB) + assertTrue("Album ID must contain artist", idA.contains("queen")) + assertTrue("Album ID must contain album title", idA.contains("greatest hits")) + + // Case and whitespace trimming normalization + val idNorm = LocalAlbum.createId(" Greatest Hits ", "QUEEN ") + assertEquals(idA, idNorm) + } + + @Test + fun localAudioTrack_albumKeyMatchesLocalAlbumId() { + val track = LocalAudioTrack( + id = 101L, + title = "Bohemian Rhapsody", + displayName = "Bohemian Rhapsody.mp3", + artist = "Queen", + album = "A Night at the Opera", + uri = Uri.parse("content://media/external/audio/media/101"), + ) + + val expectedAlbumId = LocalAlbum.createId("A Night at the Opera", "Queen") + assertEquals(expectedAlbumId, track.albumKey) + } + + @Test + fun localAudioArtist_idNormalized() { + val id1 = LocalAudioArtist.createId("The Beatles") + val id2 = LocalAudioArtist.createId(" the beatles ") + assertEquals(id1, id2) + } + + @Test + fun fileTypeUtils_audioExtensionsSeparateFromVideoExtensions() { + // Audio extensions must be recognized + assertTrue(FileTypeUtils.isAudioFileName("song.mp3")) + assertTrue(FileTypeUtils.isAudioFileName("track.flac")) + assertTrue(FileTypeUtils.isAudioFileName("audio.wav")) + assertTrue(FileTypeUtils.isAudioFileName("music.m4a")) + assertTrue(FileTypeUtils.isAudioFileName("sound.opus")) + + // Audio files must NOT be reported as video files (prevents polluting video folders/tabs) + assertFalse(FileTypeUtils.VIDEO_EXTENSIONS.contains("mp3")) + assertFalse(FileTypeUtils.VIDEO_EXTENSIONS.contains("flac")) + assertFalse(FileTypeUtils.VIDEO_EXTENSIONS.contains("wav")) + assertFalse(FileTypeUtils.VIDEO_EXTENSIONS.contains("m4a")) + assertFalse(FileTypeUtils.isVideoFile(File("/storage/song.mp3"))) + } +} diff --git a/app/src/test/java/app/aryan447/mpvium/preferences/BrowserPreferencesLayoutTest.kt b/app/src/test/java/app/aryan447/mpvium/preferences/BrowserPreferencesLayoutTest.kt index 74ceee54..f59389b9 100644 --- a/app/src/test/java/app/aryan447/mpvium/preferences/BrowserPreferencesLayoutTest.kt +++ b/app/src/test/java/app/aryan447/mpvium/preferences/BrowserPreferencesLayoutTest.kt @@ -168,6 +168,10 @@ class BrowserPreferencesLayoutTest { assertEquals(MediaLayoutMode.LIST, preferences.layoutFolders.defaultValue()) assertEquals(MediaLayoutMode.LIST, preferences.layoutFolders.get()) + assertEquals("layout_audio", preferences.layoutAudio.key()) + assertEquals(MediaLayoutMode.GRID, preferences.layoutAudio.defaultValue()) + assertEquals(MediaLayoutMode.GRID, preferences.layoutAudio.get()) + // mediaLayoutMode backward-compat reference assertEquals(MediaLayoutMode.LIST, preferences.mediaLayoutMode.get()) } @@ -190,11 +194,14 @@ class BrowserPreferencesLayoutTest { // Toggle Movies to LIST and Folders to GRID preferences.layoutMovies.set(MediaLayoutMode.LIST) preferences.layoutFolders.set(MediaLayoutMode.GRID) + preferences.layoutAudio.set(MediaLayoutMode.LIST) assertEquals(MediaLayoutMode.LIST, preferences.layoutMovies.get()) assertEquals("LIST", store.store["layout_movies"]) assertEquals(MediaLayoutMode.GRID, preferences.layoutFolders.get()) assertEquals("GRID", store.store["layout_folders"]) + assertEquals(MediaLayoutMode.LIST, preferences.layoutAudio.get()) + assertEquals("LIST", store.store["layout_audio"]) assertEquals(MediaLayoutMode.GRID, preferences.mediaLayoutMode.get()) } } diff --git a/app/src/test/java/app/aryan447/mpvium/ui/browser/MainTabsTest.kt b/app/src/test/java/app/aryan447/mpvium/ui/browser/MainTabsTest.kt index 8e954b10..f5103225 100644 --- a/app/src/test/java/app/aryan447/mpvium/ui/browser/MainTabsTest.kt +++ b/app/src/test/java/app/aryan447/mpvium/ui/browser/MainTabsTest.kt @@ -10,7 +10,7 @@ class MainTabsTest { @Test fun entries_containsAllExpectedTabsInCanonicalOrder() { val entries = MainTab.entries - assertEquals(7, entries.size) + assertEquals(8, entries.size) assertEquals("HOME", entries[0].key) assertEquals(0, entries[0].canonicalIndex) @@ -39,6 +39,10 @@ class MainTabsTest { assertEquals("PLAYLISTS", entries[6].key) assertEquals(5, entries[6].canonicalIndex) assertEquals("Playlists", entries[6].label) + + assertEquals("AUDIO", entries[7].key) + assertEquals(6, entries[7].canonicalIndex) + assertEquals("Audio", entries[7].label) } @Test @@ -49,9 +53,10 @@ class MainTabsTest { assertEquals(MainTab.FOLDERS, MainTab.fromCanonicalIndex(3)) assertEquals(MainTab.LIBRARY, MainTab.fromCanonicalIndex(4)) assertEquals(MainTab.PLAYLISTS, MainTab.fromCanonicalIndex(5)) + assertEquals(MainTab.AUDIO, MainTab.fromCanonicalIndex(6)) assertNull(MainTab.fromCanonicalIndex(-1)) - assertNull(MainTab.fromCanonicalIndex(6)) + assertNull(MainTab.fromCanonicalIndex(7)) } @Test @@ -63,39 +68,48 @@ class MainTabsTest { assertEquals(MainTab.FOLDERS, MainTab.fromKey("FOLDERS")) assertEquals(MainTab.LIBRARY, MainTab.fromKey("LIBRARY")) assertEquals(MainTab.PLAYLISTS, MainTab.fromKey("PLAYLISTS")) + assertEquals(MainTab.AUDIO, MainTab.fromKey("AUDIO")) assertNull(MainTab.fromKey("UNKNOWN")) } @Test - fun defaultKeys_containsAllFiveTabs() { - val expected = setOf("HOME", "TITLES", "FOLDERS", "LIBRARY", "PLAYLISTS") + fun defaultKeys_containsAllExpectedTabs() { + val expected = setOf("HOME", "TITLES", "FOLDERS", "LIBRARY", "PLAYLISTS", "AUDIO") assertEquals(expected, MainTab.defaultKeys) assertTrue("PLAYLISTS must be in defaultKeys for default visibility on", MainTab.defaultKeys.contains("PLAYLISTS")) assertTrue("TITLES must be in defaultKeys", MainTab.defaultKeys.contains("TITLES")) + assertTrue("AUDIO must be in defaultKeys", MainTab.defaultKeys.contains("AUDIO")) + } + + @Test + fun visibleTabs_withAllDefaultKeysAndHasAudio_returnsSixTabsInCanonicalOrder() { + val visible = MainTab.visibleTabs(MainTab.defaultKeys, hasAudio = true) + assertEquals(6, visible.size) + assertEquals(listOf(MainTab.HOME, MainTab.TITLES, MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS, MainTab.AUDIO), visible) } @Test - fun visibleTabs_withAllDefaultKeys_returnsFiveTabsInCanonicalOrder() { - val visible = MainTab.visibleTabs(MainTab.defaultKeys) + fun visibleTabs_withAllDefaultKeysWhenNoAudio_autoHidesAudioTab() { + val visible = MainTab.visibleTabs(MainTab.defaultKeys, hasAudio = false) assertEquals(5, visible.size) assertEquals(listOf(MainTab.HOME, MainTab.TITLES, MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS), visible) } @Test - fun visibleTabs_withSeparateShowsAndMoviesKeys_returnsSixTabsInCanonicalOrder() { - val enabled = setOf("HOME", "SHOWS", "MOVIES", "FOLDERS", "LIBRARY", "PLAYLISTS") - val visible = MainTab.visibleTabs(enabled) - assertEquals(6, visible.size) - assertEquals(listOf(MainTab.HOME, MainTab.SHOWS, MainTab.MOVIES, MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS), visible) + fun visibleTabs_withSeparateShowsAndMoviesKeys_returnsExpectedTabsInCanonicalOrder() { + val enabled = setOf("HOME", "SHOWS", "MOVIES", "FOLDERS", "LIBRARY", "PLAYLISTS", "AUDIO") + val visible = MainTab.visibleTabs(enabled, hasAudio = true) + assertEquals(7, visible.size) + assertEquals(listOf(MainTab.HOME, MainTab.SHOWS, MainTab.MOVIES, MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS, MainTab.AUDIO), visible) } @Test fun visibleTabs_whenTitlesEnabled_suppressesShowsAndMovies() { - val enabled = setOf("HOME", "TITLES", "SHOWS", "MOVIES", "FOLDERS", "LIBRARY", "PLAYLISTS") - val visible = MainTab.visibleTabs(enabled) - assertEquals(5, visible.size) - assertEquals(listOf(MainTab.HOME, MainTab.TITLES, MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS), visible) + val enabled = setOf("HOME", "TITLES", "SHOWS", "MOVIES", "FOLDERS", "LIBRARY", "PLAYLISTS", "AUDIO") + val visible = MainTab.visibleTabs(enabled, hasAudio = true) + assertEquals(6, visible.size) + assertEquals(listOf(MainTab.HOME, MainTab.TITLES, MainTab.FOLDERS, MainTab.LIBRARY, MainTab.PLAYLISTS, MainTab.AUDIO), visible) } @Test From 570a529370d2707738eb6163a91cf4da17f43958 Mon Sep 17 00:00:00 2001 From: aryan447 Date: Sat, 7 Nov 2026 18:35:12 +0530 Subject: [PATCH 2/3] fix(audio): resolve compilation errors in AudioScreen and AudioPlayerSurface - PullRefreshBox: pass MutableState isRefreshing instead of Boolean refreshing. - ExpressiveChoiceRow: resolve string resources outside the non-composable labelOf lambda. - EmptyState: supply missing icon parameter (Audiotrack for empty library, Search for empty query). - AudioPlayerSurface: correct RepeatMode package import to app.aryan447.mpvium.ui.player.RepeatMode. - AudioPlayerSurface: collect paused, pos, and duration from MPVLib property flows. - AudioPlayerSurface: replace nonexistent togglePause() with viewModel.pauseUnpause(). - AudioPlayerSurface: add missing player transport string resources in strings.xml. - AudioPlayerSurface: safe-call playerActivity?.getPlaylistItemTitle() and invoke public viewModel.playPlaylistItem(). --- .../aryan447/mpvium/ui/audio/AudioScreen.kt | 20 +++++++++++++++---- .../ui/player/audio/AudioPlayerSurface.kt | 15 +++++++------- app/src/main/res/values/strings.xml | 5 +++++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt index de535553..3382c3de 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/audio/AudioScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api @@ -35,6 +36,8 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -89,6 +92,7 @@ object AudioScreen : Screen { val tracks by vm.filteredTracks.collectAsState() val artists by vm.filteredArtists.collectAsState() val libraryData by vm.libraryData.collectAsState() + val isRefreshing = remember { mutableStateOf(false) } BackHandler(enabled = isSearching) { vm.setSearching(false) @@ -177,7 +181,7 @@ object AudioScreen : Screen { }, ) { padding -> PullRefreshBox( - refreshing = isLoading, + isRefreshing = isRefreshing, onRefresh = { vm.refresh() }, modifier = Modifier .fillMaxSize() @@ -190,15 +194,19 @@ object AudioScreen : Screen { .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { + val albumsLabel = stringResource(R.string.audio_filter_albums) + val tracksLabel = stringResource(R.string.audio_filter_tracks) + val artistsLabel = stringResource(R.string.audio_filter_artists) + ExpressiveChoiceRow( options = AudioTabFilter.entries, selected = filter, onSelect = { vm.setFilter(it) }, labelOf = { when (it) { - AudioTabFilter.ALBUMS -> stringResource(R.string.audio_filter_albums) - AudioTabFilter.TRACKS -> stringResource(R.string.audio_filter_tracks) - AudioTabFilter.ARTISTS -> stringResource(R.string.audio_filter_artists) + AudioTabFilter.ALBUMS -> albumsLabel + AudioTabFilter.TRACKS -> tracksLabel + AudioTabFilter.ARTISTS -> artistsLabel } }, modifier = Modifier.fillMaxWidth(), @@ -209,6 +217,7 @@ object AudioScreen : Screen { LoadingState(modifier = Modifier.fillMaxSize()) } else if (libraryData.tracks.isEmpty()) { EmptyState( + icon = Icons.Filled.Audiotrack, title = stringResource(R.string.audio_empty_no_audio_title), message = stringResource(R.string.audio_empty_no_audio_message), modifier = Modifier @@ -222,6 +231,7 @@ object AudioScreen : Screen { AudioTabFilter.ALBUMS -> { if (albums.isEmpty()) { EmptyState( + icon = Icons.Filled.Search, title = stringResource(R.string.audio_empty_search_title), message = stringResource(R.string.audio_empty_search_message, searchQuery), modifier = Modifier.fillMaxSize(), @@ -263,6 +273,7 @@ object AudioScreen : Screen { AudioTabFilter.TRACKS -> { if (tracks.isEmpty()) { EmptyState( + icon = Icons.Filled.Search, title = stringResource(R.string.audio_empty_search_title), message = stringResource(R.string.audio_empty_search_message, searchQuery), modifier = Modifier.fillMaxSize(), @@ -287,6 +298,7 @@ object AudioScreen : Screen { AudioTabFilter.ARTISTS -> { if (artists.isEmpty()) { EmptyState( + icon = Icons.Filled.Search, title = stringResource(R.string.audio_empty_search_title), message = stringResource(R.string.audio_empty_search_message, searchQuery), modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt index cf0cb3b2..db0cfa5d 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt @@ -74,12 +74,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.aryan447.mpvium.R -import app.aryan447.mpvium.preferences.RepeatMode import app.aryan447.mpvium.ui.audio.components.AlbumArtImage import app.aryan447.mpvium.ui.player.PlayerActivity import app.aryan447.mpvium.ui.player.PlayerViewModel +import app.aryan447.mpvium.ui.player.RepeatMode import app.aryan447.mpvium.ui.theme.LocalGlass import app.aryan447.mpvium.ui.theme.glassRimBrush +import is.xyz.mpv.MPVLib import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -99,10 +100,10 @@ fun AudioPlayerSurface( val album by viewModel.audioAlbum.collectAsState() val artworkUri by viewModel.audioArtUri.collectAsState() - val paused by viewModel.paused.collectAsState() + val paused by MPVLib.propBoolean["pause"].collectAsState() val isPlaying = paused == false - val pos by viewModel.pos.collectAsState() - val duration by viewModel.duration.collectAsState() + val pos by MPVLib.propInt["time-pos"].collectAsState() + val duration by MPVLib.propInt["duration"].collectAsState() val shuffleEnabled by viewModel.shuffleEnabled.collectAsState() val repeatMode by viewModel.repeatMode.collectAsState() @@ -347,7 +348,7 @@ fun AudioPlayerSurface( FilledIconButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - viewModel.togglePause() + viewModel.pauseUnpause() }, shape = CircleShape, modifier = Modifier.size(68.dp), @@ -449,7 +450,7 @@ fun AudioPlayerSurface( ) { itemsIndexed(playlist) { index, uri -> val isSelected = index == currentIndex - val itemTitle = playerActivity.getPlaylistItemTitle(uri) + val itemTitle = playerActivity?.getPlaylistItemTitle(uri) ?: (uri.lastPathSegment ?: "") Row( modifier = Modifier @@ -464,7 +465,7 @@ fun AudioPlayerSurface( ) .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress) - playerActivity.loadPlaylistItem(index) + viewModel.playPlaylistItem(index) showQueueSheet = false } .padding(horizontal = 12.dp, vertical = 10.dp), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c98e7e8..b0cda73a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -767,4 +767,9 @@ Unknown Album Unknown Track Audio tab + Previous + Next + Play + Pause + Repeat From 106a26d208a61eb32ce5c51f73112f63d2a12f75 Mon Sep 17 00:00:00 2001 From: aryan447 Date: Sat, 7 Nov 2026 18:49:18 +0530 Subject: [PATCH 3/3] fix(audio): escape 'is' keyword in MPVLib package import --- .../app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt b/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt index db0cfa5d..775edc5d 100644 --- a/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt +++ b/app/src/main/java/app/aryan447/mpvium/ui/player/audio/AudioPlayerSurface.kt @@ -80,7 +80,7 @@ import app.aryan447.mpvium.ui.player.PlayerViewModel import app.aryan447.mpvium.ui.player.RepeatMode import app.aryan447.mpvium.ui.theme.LocalGlass import app.aryan447.mpvium.ui.theme.glassRimBrush -import is.xyz.mpv.MPVLib +import `is`.xyz.mpv.MPVLib import java.util.Locale @OptIn(ExperimentalMaterial3Api::class)