diff --git a/.github/actions/setup-coverart/action.yml b/.github/actions/setup-coverart/action.yml new file mode 100644 index 0000000000..8f338e021b --- /dev/null +++ b/.github/actions/setup-coverart/action.yml @@ -0,0 +1,25 @@ +name: Setup Coverart Native Library +description: Download prebuilt coverart native library from workflow artifacts + +runs: + using: composite + steps: + - name: Download coverart library + run: | + echo "Downloading coverart library..." + + # Use nightly.link to get public URL for the artifact + DOWNLOAD_URL="https://nightly.link/MetrolistGroup/metrolist-coverart-lib/workflows/build-release/main/libcoverart-jniLibs.zip" + + curl -L -o /tmp/libcoverart-jniLibs.zip "$DOWNLOAD_URL" + + # Extract to app/src/main/jniLibs + mkdir -p app/src/main/jniLibs + unzip -o /tmp/libcoverart-jniLibs.zip -d app/src/main/jniLibs + + # Cleanup + rm /tmp/libcoverart-jniLibs.zip + + echo "Coverart library installed:" + find app/src/main/jniLibs -name "*.so" -exec ls -la {} \; + shell: bash diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5d3fd182a..f121adf4b0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,9 @@ jobs: - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf + - name: Setup Coverart Native Library + uses: ./.github/actions/setup-coverart + - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -123,6 +126,9 @@ jobs: - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf + - name: Setup Coverart Native Library + uses: ./.github/actions/setup-coverart + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index 73ab281340..4677b532b1 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -29,6 +29,9 @@ jobs: - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf + - name: Setup Coverart Native Library + uses: ./.github/actions/setup-coverart + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/build_quick.yml b/.github/workflows/build_quick.yml index e447b92e36..dfe2c142e1 100644 --- a/.github/workflows/build_quick.yml +++ b/.github/workflows/build_quick.yml @@ -40,6 +40,9 @@ jobs: - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf + - name: Setup Coverart Native Library + uses: ./.github/actions/setup-coverart + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7bfd0de80..18efc48800 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,6 +103,9 @@ jobs: - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf + - name: Setup Coverart Native Library + uses: ./.github/actions/setup-coverart + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.gitignore b/.gitignore index a09ae0ffee..4421189d5b 100755 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,6 @@ app/src/main/java/com/metrolist/music/listentogether/proto/* # FFTW third-party library build artifacts .build-fftw app/src/main/cpp/coverart/ + +# Prebuilt native libraries (downloaded during build) +app/src/main/jniLibs/ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 07b0689226..d7be821075 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -195,6 +195,14 @@ -keep class com.metrolist.music.listentogether.proto.** { *; } -keepclassmembers class com.metrolist.music.listentogether.proto.** { *; } +## CoverArt Native JNI and metadata embedding +-keep class com.metrolist.music.utils.CoverArtNative { *; } +-keepclassmembers class com.metrolist.music.utils.CoverArtNative { + native ; +} +-keep class com.metrolist.music.utils.CoverArtEmbedder { *; } +-keep class com.metrolist.music.utils.DownloadExportHelper { *; } + ## Shazam Models -keep class com.metrolist.shazamkit.models.** { *; } -keepclassmembers class com.metrolist.shazamkit.models.** { diff --git a/app/schemas/com.metrolist.music.db.InternalDatabase/35.json b/app/schemas/com.metrolist.music.db.InternalDatabase/35.json index 12de7f4b1e..757e369fdd 100644 --- a/app/schemas/com.metrolist.music.db.InternalDatabase/35.json +++ b/app/schemas/com.metrolist.music.db.InternalDatabase/35.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 35, - "identityHash": "73924a5ef1b9fb713b5e197988a0c633", + "identityHash": "bc001f6b5493d53559519d1df971006d", "entities": [ { "tableName": "song", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT NULL, `downloadUri` TEXT DEFAULT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -160,6 +160,12 @@ "columnName": "uploadEntityId", "affinity": "TEXT", "defaultValue": "NULL" + }, + { + "fieldPath": "downloadUri", + "columnName": "downloadUri", + "affinity": "TEXT", + "defaultValue": "NULL" } ], "primaryKey": { @@ -1335,7 +1341,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '73924a5ef1b9fb713b5e197988a0c633')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc001f6b5493d53559519d1df971006d')" ] } -} +} \ No newline at end of file diff --git a/app/schemas/com.metrolist.music.db.InternalDatabase/36.json b/app/schemas/com.metrolist.music.db.InternalDatabase/36.json index 30ebeece7f..f2175d5542 100644 --- a/app/schemas/com.metrolist.music.db.InternalDatabase/36.json +++ b/app/schemas/com.metrolist.music.db.InternalDatabase/36.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 36, - "identityHash": "afcd734f45bc50034a6692f5255e7b92", + "identityHash": "35dab5008e4f24f10662df49b1e52193", "entities": [ { "tableName": "song", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT NULL, `isCached` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT NULL, `isCached` INTEGER NOT NULL DEFAULT 0, `downloadUri` TEXT DEFAULT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -167,6 +167,12 @@ "affinity": "INTEGER", "notNull": true, "defaultValue": "0" + }, + { + "fieldPath": "downloadUri", + "columnName": "downloadUri", + "affinity": "TEXT", + "defaultValue": "NULL" } ], "primaryKey": { @@ -1342,7 +1348,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'afcd734f45bc50034a6692f5255e7b92')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35dab5008e4f24f10662df49b1e52193')" ] } } \ No newline at end of file diff --git a/app/setup_coverart.sh b/app/setup_coverart.sh new file mode 100755 index 0000000000..2518e9e4e6 --- /dev/null +++ b/app/setup_coverart.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Downloads the prebuilt coverart native library for local development + +DOWNLOAD_URL="https://nightly.link/MetrolistGroup/metrolist-coverart-lib/workflows/build-release/main/libcoverart-jniLibs.zip" + +echo "Downloading coverart library from latest build..." + +# Download the zip file +curl -L -o /tmp/libcoverart-jniLibs.zip "$DOWNLOAD_URL" + +if [ $? -ne 0 ]; then + echo "Failed to download from ${DOWNLOAD_URL}" + echo "The workflow may not have run yet. Please check:" + echo "https://github.com/MetrolistGroup/metrolist-coverart-lib/actions" + exit 1 +fi + +# Create jniLibs directory +mkdir -p src/main/jniLibs + +# Extract +unzip -o /tmp/libcoverart-jniLibs.zip -d src/main/jniLibs + +# Cleanup +rm /tmp/libcoverart-jniLibs.zip + +echo "Coverart library installed successfully:" +find src/main/jniLibs -name "*.so" -exec ls -la {} \; diff --git a/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt b/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt index ba0d03dd85..c27bd3dfbd 100644 --- a/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt +++ b/app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt @@ -118,6 +118,10 @@ val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize") val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize") val EnableSongCacheKey = booleanPreferencesKey("enableSongCache") +// Custom download path +val CustomDownloadPathEnabledKey = booleanPreferencesKey("customDownloadPathEnabled") +val CustomDownloadPathUriKey = stringPreferencesKey("customDownloadPathUri") + val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory") val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory") val DisableScreenshotKey = booleanPreferencesKey("disableScreenshot") diff --git a/app/src/main/kotlin/com/metrolist/music/db/DatabaseDao.kt b/app/src/main/kotlin/com/metrolist/music/db/DatabaseDao.kt index bc5fff9cd0..aae7d25204 100644 --- a/app/src/main/kotlin/com/metrolist/music/db/DatabaseDao.kt +++ b/app/src/main/kotlin/com/metrolist/music/db/DatabaseDao.kt @@ -1130,6 +1130,12 @@ interface DatabaseDao { @Query("SELECT playbackPosition FROM song WHERE id = :songId") fun playbackPositionFlow(songId: String): Flow + @Query("UPDATE song SET downloadUri = :uri WHERE id = :songId") + fun updateDownloadUri(songId: String, uri: String?) + + @Query("SELECT downloadUri FROM song WHERE id = :songId") + fun getDownloadUri(songId: String): String? + @Transaction @Query("SELECT * FROM song WHERE isUploaded = 1 ORDER BY dateDownload") fun uploadedSongsByCreateDateAsc(): Flow> diff --git a/app/src/main/kotlin/com/metrolist/music/db/entities/SongEntity.kt b/app/src/main/kotlin/com/metrolist/music/db/entities/SongEntity.kt index 54d11f6e25..4416a2c2e7 100644 --- a/app/src/main/kotlin/com/metrolist/music/db/entities/SongEntity.kt +++ b/app/src/main/kotlin/com/metrolist/music/db/entities/SongEntity.kt @@ -63,7 +63,9 @@ data class SongEntity( @ColumnInfo(name = "uploadEntityId", defaultValue = "NULL") val uploadEntityId: String? = null, @ColumnInfo(name = "isCached", defaultValue = "0") - val isCached: Boolean = false + val isCached: Boolean = false, + @ColumnInfo(name = "downloadUri", defaultValue = "NULL") + val downloadUri: String? = null ) { fun localToggleLike() = copy( liked = !liked, diff --git a/app/src/main/kotlin/com/metrolist/music/playback/DownloadUtil.kt b/app/src/main/kotlin/com/metrolist/music/playback/DownloadUtil.kt index 57f935a1a9..2749c1325f 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/DownloadUtil.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/DownloadUtil.kt @@ -20,13 +20,18 @@ import androidx.media3.exoplayer.offline.DownloadNotificationHelper import com.metrolist.innertube.YouTube import com.metrolist.music.constants.AudioQuality import com.metrolist.music.constants.AudioQualityKey +import com.metrolist.music.constants.CustomDownloadPathEnabledKey +import com.metrolist.music.constants.CustomDownloadPathUriKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.di.DownloadCache import com.metrolist.music.di.PlayerCache +import com.metrolist.music.utils.DownloadExportHelper import com.metrolist.music.utils.YTPlayerUtils +import com.metrolist.music.utils.booleanPreference import com.metrolist.music.utils.enumPreference +import com.metrolist.music.utils.stringPreference import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -50,18 +55,55 @@ import javax.inject.Singleton class DownloadUtil @Inject constructor( - @ApplicationContext context: Context, + @ApplicationContext private val context: Context, val database: MusicDatabase, val databaseProvider: DatabaseProvider, @DownloadCache val downloadCache: SimpleCache, @PlayerCache val playerCache: SimpleCache, + private val downloadExportHelper: DownloadExportHelper, ) { private val connectivityManager = context.getSystemService()!! private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) + private val customDownloadPathEnabled by booleanPreference(context, CustomDownloadPathEnabledKey, false) + private val customDownloadPathUri by stringPreference(context, CustomDownloadPathUriKey, "") private val songUrlCache = HashMap>() + // Map of songId -> itag for user-selected format downloads + private val targetItagOverride = mutableMapOf() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** + * Set the target itag for a specific song download. + * This will be used instead of auto-selection when downloading. + */ + fun setTargetItag(songId: String, itag: Int) { + timber.log.Timber.tag("DownloadUtil").d("Setting target itag for $songId: $itag") + targetItagOverride[songId] = itag + // Invalidate cached URL to force fresh fetch with new format + invalidateUrl(songId) + } + + /** + * Clear the target itag for a song (revert to auto-selection). + */ + fun clearTargetItag(songId: String) { + timber.log.Timber.tag("DownloadUtil").d("Clearing target itag for $songId") + targetItagOverride.remove(songId) + } + + /** + * Invalidate cached URL for a specific song. + * Call this when the stream URL needs to be refreshed (e.g., format change). + */ + fun invalidateUrl(songId: String) { + val hadEntry = songUrlCache.containsKey(songId) + songUrlCache.remove(songId) + if (hadEntry) { + timber.log.Timber.tag("DownloadUtil").d("Invalidated cached URL for: $songId") + } + } + val downloads = MutableStateFlow>(emptyMap()) private val dataSourceFactory = @@ -87,60 +129,121 @@ constructor( val mediaId = dataSpec.key ?: error("No media id") val length = if (dataSpec.length >= 0) dataSpec.length else 1 - if (playerCache.isCached(mediaId, dataSpec.position, length)) { - return@Factory dataSpec - } + // Check if user selected a specific format for this download + // IMPORTANT: Check targetItag FIRST - if set, we must bypass cache to get that specific format + val targetItag = targetItagOverride[mediaId] ?: 0 + val hasTargetItag = targetItag > 0 + timber.log.Timber.tag("DownloadUtil").d("Download resolver for $mediaId, targetItag=${if (hasTargetItag) targetItag else "auto"}") + + // Only use cache if no specific format was requested + if (!hasTargetItag) { + if (playerCache.isCached(mediaId, dataSpec.position, length)) { + timber.log.Timber.tag("DownloadUtil").d("Using player cache for $mediaId") + return@Factory dataSpec + } - songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { - return@Factory dataSpec.withUri(it.first.toUri()) + // Fixed: use > for "not expired" (was < which meant "use if expired") + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { + timber.log.Timber.tag("DownloadUtil").d("Using URL cache for $mediaId") + return@Factory dataSpec.withUri(it.first.toUri()) + } + } else { + // Clear caches when specific format requested to ensure fresh fetch + timber.log.Timber.tag("DownloadUtil").d("Bypassing cache for $mediaId - specific format requested (itag=$targetItag)") + songUrlCache.remove(mediaId) + // Also clear playerCache for this media to prevent format mismatch + runBlocking(Dispatchers.IO) { + try { + if (playerCache.getCachedSpans(mediaId).isNotEmpty()) { + playerCache.removeResource(mediaId) + timber.log.Timber.tag("DownloadUtil").d("Cleared player cache for $mediaId") + } + } catch (_: Exception) {} + } } + timber.log.Timber.tag("DownloadUtil").d("Fetching fresh stream for $mediaId, targetItag=${if (hasTargetItag) targetItag else "auto"}") + val playbackData = runBlocking(Dispatchers.IO) { YTPlayerUtils.playerResponseForPlayback( mediaId, audioQuality = audioQuality, connectivityManager = connectivityManager, + targetItag = targetItag, ) }.getOrThrow() val format = playbackData.format + timber.log.Timber.tag("DownloadUtil").i( + "Download stream for $mediaId: itag=${format.itag}, mimeType=${format.mimeType.split(";")[0]}, bitrate=${format.bitrate/1000}kbps" + ) - database.query { - upsert( - FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playbackData.audioConfig?.loudnessDb, - perceptualLoudnessDb = playbackData.audioConfig?.perceptualLoudnessDb, - playbackUrl = playbackData.playbackTracking?.videostatsPlaybackUrl?.baseUrl - ), - ) + // Store format synchronously to ensure it's available for metadata embedding after download + runBlocking(Dispatchers.IO) { + database.withTransaction { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playbackData.audioConfig?.loudnessDb, + perceptualLoudnessDb = playbackData.audioConfig?.perceptualLoudnessDb, + playbackUrl = playbackData.playbackTracking?.videostatsPlaybackUrl?.baseUrl + ), + ) - val now = LocalDateTime.now() - val existing = getSongByIdBlocking(mediaId)?.song + val now = LocalDateTime.now() + val existing = getSongByIdBlocking(mediaId)?.song - val updatedSong = if (existing != null) { - if (existing.dateDownload == null) { - existing.copy(dateDownload = now) + val updatedSong = if (existing != null) { + if (existing.dateDownload == null) { + existing.copy(dateDownload = now) + } else { + existing + } } else { - existing + SongEntity( + id = mediaId, + title = playbackData.videoDetails?.title ?: "Unknown", + duration = playbackData.videoDetails?.lengthSeconds?.toIntOrNull() ?: 0, + thumbnailUrl = playbackData.videoDetails?.thumbnail?.thumbnails?.lastOrNull()?.url, + dateDownload = now, + isDownloaded = false + ) } - } else { - SongEntity( - id = mediaId, - title = playbackData.videoDetails?.title ?: "Unknown", - duration = playbackData.videoDetails?.lengthSeconds?.toIntOrNull() ?: 0, - thumbnailUrl = playbackData.videoDetails?.thumbnail?.thumbnails?.lastOrNull()?.url, - dateDownload = now, - isDownloaded = false - ) - } - upsert(updatedSong) + upsert(updatedSong) + + // Create artist relationship if song is new and has author info + val videoDetails = playbackData.videoDetails + val authorName = videoDetails?.author + if (existing == null && authorName != null) { + val channelId = videoDetails.channelId + // Check if artist exists, create if not + val existingArtist = artistByName(authorName) + val artistId = existingArtist?.id ?: channelId + if (existingArtist == null) { + insert( + com.metrolist.music.db.entities.ArtistEntity( + id = artistId, + name = authorName, + channelId = channelId + ) + ) + } + // Create song-artist relationship + insert( + com.metrolist.music.db.entities.SongArtistMap( + songId = mediaId, + artistId = artistId, + position = 0 + ) + ) + } + } } val streamUrl = playbackData.streamUrl.let { @@ -178,16 +281,77 @@ constructor( } scope.launch { + val songId = download.request.id + timber.log.Timber.tag("DownloadUtil").d("onDownloadChanged: songId=$songId, state=${download.state}") + when (download.state) { Download.STATE_COMPLETED -> { - database.updateDownloadedInfo(download.request.id, true, LocalDateTime.now()) + timber.log.Timber.tag("DownloadUtil").d("Download completed for: $songId") + // Clear target itag override now that download is done + clearTargetItag(songId) + database.updateDownloadedInfo(songId, true, LocalDateTime.now()) + + // Export to custom path if enabled + timber.log.Timber.tag("DownloadUtil").d( + "Custom path enabled: $customDownloadPathEnabled, URI: ${customDownloadPathUri.take(50)}..." + ) + if (customDownloadPathEnabled && customDownloadPathUri.isNotEmpty()) { + timber.log.Timber.tag("DownloadUtil").d("Starting export to custom path for: $songId") + try { + val result = downloadExportHelper.exportToCustomPath( + songId, + customDownloadPathUri + ) + if (result != null) { + timber.log.Timber.tag("DownloadUtil") + .d("Export successful for $songId: $result") + } else { + timber.log.Timber.tag("DownloadUtil") + .w("Export returned null for: $songId") + } + } catch (e: Exception) { + // Log error but don't fail - internal cache still works + timber.log.Timber.tag("DownloadUtil") + .e(e, "Failed to export to custom path for: $songId") + } + } else { + timber.log.Timber.tag("DownloadUtil") + .d("Custom path export skipped - not enabled or URI empty") + } + } + Download.STATE_FAILED -> { + timber.log.Timber.tag("DownloadUtil").w("Download failed for: $songId") + clearTargetItag(songId) + database.updateDownloadedInfo(songId, false, null) + } + Download.STATE_STOPPED -> { + timber.log.Timber.tag("DownloadUtil").d("Download stopped for: $songId") + clearTargetItag(songId) + database.updateDownloadedInfo(songId, false, null) } - Download.STATE_FAILED, - Download.STATE_STOPPED, Download.STATE_REMOVING -> { - database.updateDownloadedInfo(download.request.id, false, null) + timber.log.Timber.tag("DownloadUtil").d("Download removing for: $songId") + clearTargetItag(songId) + database.updateDownloadedInfo(songId, false, null) + + // Also clean up external file if exists + timber.log.Timber.tag("DownloadUtil") + .d("Attempting to delete external file for: $songId") + try { + val deleted = downloadExportHelper.deleteFromCustomPath( + songId, + if (customDownloadPathEnabled && customDownloadPathUri.isNotEmpty()) customDownloadPathUri else null + ) + timber.log.Timber.tag("DownloadUtil") + .d("External file deletion result: $deleted for: $songId") + } catch (e: Exception) { + timber.log.Timber.tag("DownloadUtil") + .e(e, "Failed to delete from custom path for: $songId") + } } else -> { + timber.log.Timber.tag("DownloadUtil") + .d("Unhandled download state ${download.state} for: $songId") } } } diff --git a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt index 80af40f775..d9976014df 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt @@ -2873,20 +2873,45 @@ class MusicService : // Check if we need to bypass cache for quality change val shouldBypassCache = bypassCacheForQualityChange.contains(mediaId) + // ZEMER APPROACH: Check for downloaded file URI first (local playback) + // Only use local file if starting from beginning (position 0) to avoid + // switching sources mid-stream when a download completes during playback if (!shouldBypassCache) { val usePlayerCache = dataStore.get(EnableSongCacheKey, true) - if (downloadCache.isCached( + val song = runBlocking(Dispatchers.IO) { + database.song(mediaId).first() + } + + // Use downloaded file directly if available (bypasses ExoPlayer cache entirely) + if (song?.song?.downloadUri != null && dataSpec.position == 0L) { + Timber.tag("CacheResolver").d("Using downloaded file for $mediaId: ${song.song.downloadUri}") + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(song.song.downloadUri.toUri()) + } + + // Fallback: Check download cache for old downloads without downloadUri + // This handles songs downloaded before the custom path feature was added + if (song?.song?.downloadUri == null && downloadCache.isCached( mediaId, dataSpec.position, if (dataSpec.length >= 0) dataSpec.length else 1 - ) || - (usePlayerCache && playerCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) + ) ) { + Timber.tag("CacheResolver").d("Using download cache (legacy) for $mediaId at pos=${dataSpec.position}") + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec + } + + // Check player cache (streaming buffer) if enabled + if (usePlayerCache && playerCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { + Timber.tag("CacheResolver").d("Using player cache for $mediaId at pos=${dataSpec.position}") scope.launch(Dispatchers.IO) { recoverSong(mediaId) } return@Factory dataSpec } + // Check URL cache songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { + Timber.tag("CacheResolver").d("Using URL cache for $mediaId") scope.launch(Dispatchers.IO) { recoverSong(mediaId) } return@Factory dataSpec.withUri(it.first.toUri()) } @@ -2979,7 +3004,9 @@ class MusicService : DefaultMediaSourceFactory( createDataSourceFactory(), ExtractorsFactory { - arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) + // FragmentedMp4Extractor first for M4A downloads, MatroskaExtractor for WebM/OPUS streams + // Order matters: ExoPlayer tries extractors in order until sniff() succeeds + arrayOf(FragmentedMp4Extractor(), MatroskaExtractor()) }, ) diff --git a/app/src/main/kotlin/com/metrolist/music/ui/component/DownloadFormatDialog.kt b/app/src/main/kotlin/com/metrolist/music/ui/component/DownloadFormatDialog.kt new file mode 100644 index 0000000000..cfd94f72d9 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/ui/component/DownloadFormatDialog.kt @@ -0,0 +1,319 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.metrolist.music.R +import com.metrolist.music.utils.YTPlayerUtils.AudioFormatOption + +/** + * Dialog to let user choose download format/quality. + * Shows available audio formats sorted by bitrate (highest first). + * M4A formats show a badge indicating metadata will be embedded. + */ +@Composable +fun DownloadFormatDialog( + isLoading: Boolean, + formats: List, + onFormatSelected: (AudioFormatOption) -> Unit, + onDismiss: () -> Unit, +) { + DefaultDialog( + onDismiss = onDismiss, + icon = { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + } + }, + title = { + Text( + text = stringResource(R.string.choose_download_quality), + style = MaterialTheme.typography.headlineSmall + ) + }, + buttons = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) { + when { + isLoading -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.loading_formats), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + formats.isEmpty() -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + ) { + Text( + text = stringResource(R.string.no_formats_available), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + // Quality legend + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + QualityLegendItem( + color = MaterialTheme.colorScheme.onSurface, + label = stringResource(R.string.quality_high) + ) + Spacer(modifier = Modifier.width(16.dp)) + QualityLegendItem( + color = MaterialTheme.colorScheme.primary, + label = stringResource(R.string.quality_medium) + ) + Spacer(modifier = Modifier.width(16.dp)) + QualityLegendItem( + color = MaterialTheme.colorScheme.onSurfaceVariant, + label = stringResource(R.string.quality_low) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Format list + LazyColumn( + modifier = Modifier.height(280.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(formats) { index, format -> + FormatCard( + format = format, + isBest = index == 0, + qualityTier = getQualityTier(format.bitrateKbps), + onClick = { onFormatSelected(format) } + ) + } + } + + // Note about metadata + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.m4a_metadata_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 16.sp + ) + } + } + } + } +} + +@Composable +private fun QualityLegendItem( + color: androidx.compose.ui.graphics.Color, + label: String +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color) + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun FormatCard( + format: AudioFormatOption, + isBest: Boolean, + qualityTier: QualityTier, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = false, + onClick = onClick, + colors = RadioButtonDefaults.colors( + unselectedColor = qualityTier.color() + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "${format.bitrateKbps} kbps", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + if (isBest) { + Surface( + color = MaterialTheme.colorScheme.tertiary, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = stringResource(R.string.best_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + if (format.supportsMetadata) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = stringResource(R.string.metadata_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + Text( + text = "${format.codec} • ${if (format.codec == "OPUS") "WebM" else "M4A"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + IconButton(onClick = onClick) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +private enum class QualityTier { + HIGH, MEDIUM, LOW; + + @Composable + fun color(): androidx.compose.ui.graphics.Color = when (this) { + HIGH -> MaterialTheme.colorScheme.onSurface + MEDIUM -> MaterialTheme.colorScheme.primary + LOW -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +private fun getQualityTier(bitrateKbps: Int): QualityTier = when { + bitrateKbps >= 128 -> QualityTier.HIGH + bitrateKbps >= 64 -> QualityTier.MEDIUM + else -> QualityTier.LOW +} diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt index cc98e4d2f0..aa189ebc23 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt @@ -8,7 +8,6 @@ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration -import android.widget.Toast import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image @@ -75,8 +74,8 @@ import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.db.entities.Album -import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SpeedDialItem +import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue @@ -88,12 +87,10 @@ import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.SongListItem -import com.metrolist.music.ui.menu.ExportDialog -import com.metrolist.music.utils.PlaylistExporter -import com.metrolist.music.utils.getExportFileUri -import com.metrolist.music.utils.saveToPublicDocuments import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber @SuppressLint("MutableCollectionMutableState") @Composable @@ -135,8 +132,8 @@ fun AlbumMenu( STATE_COMPLETED } else if (songs.all { downloads[it.id]?.state == STATE_QUEUED || - downloads[it.id]?.state == STATE_DOWNLOADING || - downloads[it.id]?.state == STATE_COMPLETED + downloads[it.id]?.state == STATE_DOWNLOADING || + downloads[it.id]?.state == STATE_COMPLETED } ) { STATE_DOWNLOADING @@ -172,6 +169,40 @@ fun AlbumMenu( mutableStateOf(mutableListOf()) } + // Download format picker state + var showDownloadFormatDialog by rememberSaveable { mutableStateOf(false) } + var availableFormats by remember { mutableStateOf>(emptyList()) } + var isLoadingFormats by remember { mutableStateOf(false) } + + if (showDownloadFormatDialog) { + com.metrolist.music.ui.component.DownloadFormatDialog( + isLoading = isLoadingFormats, + formats = availableFormats, + onFormatSelected = { format -> + Timber.tag("AlbumMenu").d("Format selected for album: ${format.displayName} (itag=${format.itag})") + showDownloadFormatDialog = false + // Download all songs with selected format + songs.forEach { song -> + downloadUtil.setTargetItag(song.id, format.itag) + val downloadRequest = DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onDismiss = { + showDownloadFormatDialog = false + } + ) + } + AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, onGetSong = { playlist -> @@ -208,8 +239,8 @@ fun AlbumMenu( ) }, modifier = - Modifier - .clickable { showErrorPlaylistAddDialog = false }, + Modifier + .clickable { showErrorPlaylistAddDialog = false }, ) } @@ -230,13 +261,14 @@ fun AlbumMenu( Row( verticalAlignment = Alignment.CenterVertically, modifier = - Modifier - .height(ListItemHeight) - .clickable { - navController.navigate("artist/${artist.id}") - showSelectArtistDialog = false - onDismiss() - }.padding(horizontal = 12.dp), + Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), ) { Box( modifier = Modifier.padding(8.dp), @@ -246,9 +278,9 @@ fun AlbumMenu( model = artist.thumbnailUrl, contentDescription = null, modifier = - Modifier - .size(ListThumbnailSize) - .clip(CircleShape), + Modifier + .size(ListThumbnailSize) + .clip(CircleShape), ) } Text( @@ -258,9 +290,9 @@ fun AlbumMenu( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = - Modifier - .weight(1f) - .padding(horizontal = 8.dp), + Modifier + .weight(1f) + .padding(horizontal = 8.dp), ) } } @@ -296,183 +328,173 @@ fun AlbumMenu( val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( - contentPadding = - PaddingValues( - start = 0.dp, - top = 0.dp, - end = 0.dp, - bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), - ), + contentPadding = PaddingValues( + start = 0.dp, + top = 0.dp, + end = 0.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), ) { item { NewActionGrid( - actions = - listOfNotNull( - if (!isGuest) { - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.play), - onClick = { - onDismiss() - if (songs.isNotEmpty()) { - playerConnection.playQueue( - ListQueue( - title = album.album.title, - items = songs.map(Song::toMediaItem), - ), + actions = listOfNotNull( + if (!isGuest) { + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.play), + onClick = { + onDismiss() + if (songs.isNotEmpty()) { + playerConnection.playQueue( + ListQueue( + title = album.album.title, + items = songs.map(Song::toMediaItem) ) - } - }, - ) - - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - text = stringResource(R.string.shuffle), - onClick = { - onDismiss() - if (songs.isNotEmpty()) { - album.album.playlistId?.let { playlistId -> - playerConnection.service.getAutomix(playlistId) - } - playerConnection.playQueue( - ListQueue( - title = album.album.title, - items = songs.shuffled().map(Song::toMediaItem), - ), - ) - } - }, - ) - } else { - null - }, + } + } + ) + NewAction( icon = { Icon( - painter = painterResource(R.drawable.share), + painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, - text = stringResource(R.string.share), + text = stringResource(R.string.shuffle), onClick = { onDismiss() - val intent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/playlist?list=${album.album.playlistId}") + if (songs.isNotEmpty()) { + album.album.playlistId?.let { playlistId -> + playerConnection.service.getAutomix(playlistId) } - context.startActivity(Intent.createChooser(intent, null)) - }, - ), - ), + playerConnection.playQueue( + ListQueue( + title = album.album.title, + items = songs.shuffled().map(Song::toMediaItem) + ) + ) + } + } + ) + } else null, + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.share), + onClick = { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/playlist?list=${album.album.playlistId}") + } + context.startActivity(Intent.createChooser(intent, null)) + } + ) + ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), - columns = if (isGuest) 1 else 3, + columns = if (isGuest) 1 else 3 ) } item { Material3MenuGroup( - items = - listOfNotNull( - if (!isGuest) { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.play_next)) }, - description = { Text(text = stringResource(R.string.play_next_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.playlist_play), - contentDescription = null, - ) - }, - onClick = { - onDismiss() - playerConnection.playNext(songs.map { it.toMediaItem() }) - }, - ) - } else { - null - }, - if (!isGuest) { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.add_to_queue)) }, - description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.queue_music), - contentDescription = null, - ) - }, - onClick = { - onDismiss() - playerConnection.addToQueue(songs.map { it.toMediaItem() }) - }, - ) - } else { - null - }, + items = listOfNotNull( + if (!isGuest) { Material3MenuItemData( - title = { Text(text = stringResource(R.string.add_to_playlist)) }, - description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, + title = { Text(text = stringResource(R.string.play_next)) }, + description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( - painter = painterResource(R.drawable.playlist_add), - contentDescription = null, + painter = painterResource(R.drawable.playlist_play), + contentDescription = null ) }, onClick = { - showChoosePlaylistDialog = true - }, - ), + onDismiss() + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + ) + } else null, + if (!isGuest) { Material3MenuItemData( - title = { - Text( - text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial", - ) - }, + title = { Text(text = stringResource(R.string.add_to_queue)) }, + description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( - painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), - contentDescription = null, + painter = painterResource(R.drawable.queue_music), + contentDescription = null ) }, onClick = { - coroutineScope.launch(Dispatchers.IO) { - if (isPinned) { - database.speedDialDao.delete(album.id) - } else { - database.speedDialDao.insert( - SpeedDialItem( - id = album.id, - secondaryId = album.album.playlistId, - title = album.album.title, - subtitle = album.artists.joinToString(", ") { it.name }, - thumbnailUrl = album.album.thumbnailUrl, - type = "ALBUM", - explicit = album.album.explicit, - ), - ) - } - } onDismiss() - }, - ), + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + ) + } else null, + Material3MenuItemData( + title = { Text(text = stringResource(R.string.add_to_playlist)) }, + description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null + ) + }, + onClick = { + showChoosePlaylistDialog = true + } ), + Material3MenuItemData( + title = { + Text( + text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial" + ) + }, + icon = { + Icon( + painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), + contentDescription = null, + ) + }, + onClick = { + coroutineScope.launch(Dispatchers.IO) { + if (isPinned) { + database.speedDialDao.delete(album.id) + } else { + database.speedDialDao.insert( + SpeedDialItem( + id = album.id, + secondaryId = album.album.playlistId, + title = album.album.title, + subtitle = album.artists.joinToString(", ") { it.name }, + thumbnailUrl = album.album.thumbnailUrl, + type = "ALBUM", + explicit = album.album.explicit + ) + ) + } + } + onDismiss() + } + ) + ) ) } @@ -480,23 +502,50 @@ fun AlbumMenu( item { Material3MenuGroup( - items = - listOf( - when (downloadState) { - STATE_COMPLETED -> { - Material3MenuItemData( - title = { - Text( - text = stringResource(R.string.remove_download), - ) - }, - icon = { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, + items = buildList { + when (downloadState) { + STATE_COMPLETED -> { + add(Material3MenuItemData( + title = { + Text( + text = stringResource(R.string.remove_download) + ) + }, + icon = { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + }, + onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, ) - }, - onClick = { + } + } + )) + // Swap download option + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.swap_download)) }, + description = { Text(text = stringResource(R.string.swap_download_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + }, + onClick = { + if (songs.isEmpty()) return@Material3MenuItemData + Timber.tag("AlbumMenu").d("Swap download clicked for album") + showDownloadFormatDialog = true + isLoadingFormats = true + availableFormats = emptyList() + coroutineScope.launch(Dispatchers.IO) { + // Remove all existing downloads first songs.forEach { song -> DownloadService.sendRemoveDownload( context, @@ -505,207 +554,130 @@ fun AlbumMenu( false, ) } - }, - ) - } - - STATE_QUEUED, STATE_DOWNLOADING -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.downloading)) }, - icon = { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - }, - onClick = { - songs.forEach { song -> - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - song.id, - false, - ) + // Fetch formats from first song + try { + val formats = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(songs.first().id).getOrNull() ?: emptyList() + withContext(Dispatchers.Main) { + availableFormats = formats + isLoadingFormats = false + } + } catch (e: Exception) { + Timber.tag("AlbumMenu").e(e, "Failed to fetch formats for swap") + withContext(Dispatchers.Main) { + isLoadingFormats = false + showDownloadFormatDialog = false + } } - }, - ) - } - - else -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.action_download)) }, - description = { Text(text = stringResource(R.string.download_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.download), - contentDescription = null, + } + } + )) + } + STATE_QUEUED, STATE_DOWNLOADING -> { + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.downloading)) }, + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, ) - }, - onClick = { - songs.forEach { song -> - val downloadRequest = - DownloadRequest - .Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false, - ) + } + } + )) + } + else -> { + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.action_download)) }, + description = { Text(text = stringResource(R.string.download_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + }, + onClick = { + if (songs.isEmpty()) return@Material3MenuItemData + // Show format picker - fetch formats from first song + showDownloadFormatDialog = true + isLoadingFormats = true + availableFormats = emptyList() + + coroutineScope.launch(Dispatchers.IO) { + try { + val formats = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(songs.first().id).getOrNull() ?: emptyList() + withContext(Dispatchers.Main) { + availableFormats = formats + isLoadingFormats = false + } + } catch (e: Exception) { + Timber.tag("AlbumMenu").e(e, "Failed to fetch formats") + withContext(Dispatchers.Main) { + isLoadingFormats = false + showDownloadFormatDialog = false + } } - }, - ) - } - }, - ), + } + } + )) + } + } + } ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { - // Export album as a playlist (CSV/M3U) - var showExportDialog by remember { mutableStateOf(false) } Material3MenuGroup( - items = - listOf( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.export_playlist)) }, - icon = { - Icon( - painter = painterResource(R.drawable.share), - contentDescription = null, - ) - }, - onClick = { showExportDialog = true }, - ), - ), - ) - - val exportPlaylistStr = stringResource(R.string.export_playlist) - - if (showExportDialog) { - ExportDialog( - onDismiss = { showExportDialog = false }, - onShare = { format -> - val playlistSongs = - songs.map { s -> - com.metrolist.music.db.entities.PlaylistSong( - map = - com.metrolist.music.db.entities.PlaylistSongMap( - songId = s.id, - playlistId = album.id, - position = 0, - ), - song = s, - ) - } - val result = - when (format) { - "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, album.album.title, playlistSongs) - "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, album.album.title, playlistSongs) - else -> Result.failure(IllegalArgumentException("Unknown format")) + items = listOf( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.view_artist)) }, + description = { Text(text = album.artists.joinToString { it.name }) }, + icon = { + Icon( + painter = painterResource(R.drawable.artist), + contentDescription = null + ) + }, + onClick = { + if (album.artists.size == 1) { + navController.navigate("artist/${album.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true } - result - .onSuccess { file -> - val uri = getExportFileUri(context, file) - val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + ), + Material3MenuItemData( + title = { Text(text = stringResource(R.string.refetch)) }, + description = { Text(text = stringResource(R.string.refetch_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation) + ) + }, + onClick = { + refetchIconDegree -= 360 + scope.launch(Dispatchers.IO) { + YouTube.album(album.id).onSuccess { + database.transaction { + update(album.album, it, album.artists) } - context.startActivity(Intent.createChooser(shareIntent, exportPlaylistStr)) - }.onFailure { - Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() - } - showExportDialog = false - }, - onSave = { format -> - val playlistSongs = - songs.map { s -> - com.metrolist.music.db.entities.PlaylistSong( - map = - com.metrolist.music.db.entities.PlaylistSongMap( - songId = s.id, - playlistId = album.id, - position = 0, - ), - song = s, - ) - } - val export = - when (format) { - "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, album.album.title, playlistSongs) - "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, album.album.title, playlistSongs) - else -> Result.failure(IllegalArgumentException("Unknown format")) - } - export - .onSuccess { file -> - val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" - val save = saveToPublicDocuments(context, file, mimeType) - save - .onSuccess { Toast.makeText(context, R.string.export_success, Toast.LENGTH_SHORT).show() } - .onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } - }.onFailure { - Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() + } } - showExportDialog = false - }, + } + ) ) - } - } - - item { Spacer(modifier = Modifier.height(12.dp)) } - - item { - Material3MenuGroup( - items = - listOf( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.view_artist)) }, - description = { Text(text = album.artists.joinToString { it.name }) }, - icon = { - Icon( - painter = painterResource(R.drawable.artist), - contentDescription = null, - ) - }, - onClick = { - if (album.artists.size == 1) { - navController.navigate("artist/${album.artists[0].id}") - onDismiss() - } else { - showSelectArtistDialog = true - } - }, - ), - Material3MenuItemData( - title = { Text(text = stringResource(R.string.refetch)) }, - description = { Text(text = stringResource(R.string.refetch_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.sync), - contentDescription = null, - modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), - ) - }, - onClick = { - refetchIconDegree -= 360 - scope.launch(Dispatchers.IO) { - YouTube.album(album.id).onSuccess { - database.transaction { - update(album.album, it, album.artists) - } - } - } - }, - ), - ), ) } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt index 1c36f7a946..449bf99d47 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt @@ -113,25 +113,24 @@ fun PlayerMenu( val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val playerVolume = playerConnection.service.playerVolume.collectAsState() - + // Cast state for volume control - safely access castConnectionHandler to prevent crashes - val castHandler = - remember(playerConnection) { - try { - playerConnection.service.castConnectionHandler - } catch (e: Exception) { - null - } + val castHandler = remember(playerConnection) { + try { + playerConnection.service.castConnectionHandler + } catch (e: Exception) { + null } + } val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } val castVolume by castHandler?.castVolume?.collectAsState() ?: remember { mutableFloatStateOf(1f) } val castDeviceName by castHandler?.castDeviceName?.collectAsState() ?: remember { mutableStateOf(null) } - + val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null) val coroutineScope = rememberCoroutineScope() - val download by LocalDownloadUtil.current - .getDownload(mediaMetadata.id) + val downloadUtil = LocalDownloadUtil.current + val download by downloadUtil.getDownload(mediaMetadata.id) .collectAsState(initial = null) val artists = @@ -139,10 +138,43 @@ fun PlayerMenu( mediaMetadata.artists.filter { it.id != null } } + // Download format picker state + var showDownloadFormatDialog by rememberSaveable { mutableStateOf(false) } + var availableFormats by remember { mutableStateOf>(emptyList()) } + var isLoadingFormats by remember { mutableStateOf(false) } + + if (showDownloadFormatDialog) { + com.metrolist.music.ui.component.DownloadFormatDialog( + isLoading = isLoadingFormats, + formats = availableFormats, + onFormatSelected = { format -> + timber.log.Timber.tag("PlayerMenu").d("Format selected: ${format.displayName} (itag=${format.itag})") + showDownloadFormatDialog = false // Close format picker, keep menu open + // Set target itag and start download + downloadUtil.setTargetItag(mediaMetadata.id, format.itag) + val downloadRequest = + androidx.media3.exoplayer.offline.DownloadRequest + .Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + androidx.media3.exoplayer.offline.DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + }, + onDismiss = { + showDownloadFormatDialog = false + } + ) + } + var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } - + var showListenTogetherDialog by rememberSaveable { mutableStateOf(false) } @@ -150,8 +182,7 @@ fun PlayerMenu( val listenTogetherManager = LocalListenTogetherManager.current val listenTogetherRoleState = listenTogetherManager?.role?.collectAsState(initial = com.metrolist.music.listentogether.RoomRole.NONE) val isListenTogetherGuest = listenTogetherRoleState?.value == com.metrolist.music.listentogether.RoomRole.GUEST - val pendingSuggestions by listenTogetherManager?.pendingSuggestions?.collectAsState(initial = emptyList()) - ?: remember { mutableStateOf(emptyList()) } + val pendingSuggestions by listenTogetherManager?.pendingSuggestions?.collectAsState(initial = emptyList()) ?: remember { mutableStateOf(emptyList()) } AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, @@ -166,13 +197,13 @@ fun PlayerMenu( }, onDismiss = { showChoosePlaylistDialog = false - }, + } ) ListenTogetherDialog( visible = showListenTogetherDialog, mediaMetadata = mediaMetadata, - onDismiss = { showListenTogetherDialog = false }, + onDismiss = { showListenTogetherDialog = false } ) var showSelectArtistDialog by rememberSaveable { @@ -187,15 +218,16 @@ fun PlayerMenu( Box( contentAlignment = Alignment.CenterStart, modifier = - Modifier - .fillParentMaxWidth() - .height(ListItemHeight) - .clickable { - navController.navigate("artist/${artist.id}") - showSelectArtistDialog = false - playerBottomSheetState.collapseSoft() - onDismiss() - }.padding(horizontal = 24.dp), + Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + playerBottomSheetState.collapseSoft() + onDismiss() + } + .padding(horizontal = 24.dp), ) { Text( text = artist.name, @@ -221,37 +253,35 @@ fun PlayerMenu( if (isQueueTrigger != true) { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 6.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 6.dp), ) { // Show Cast indicator when casting if (isCasting && castDeviceName != null) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) ) { Icon( painter = painterResource(R.drawable.cast), contentDescription = null, modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(R.string.casting_to, castDeviceName ?: ""), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) } } - + VolumeSlider( value = if (isCasting) castVolume else playerVolume.value, onValueChange = { volume -> @@ -262,7 +292,7 @@ fun PlayerMenu( } }, modifier = Modifier.fillMaxWidth(), - accentColor = MaterialTheme.colorScheme.primary, + accentColor = MaterialTheme.colorScheme.primary ) } } @@ -277,81 +307,68 @@ fun PlayerMenu( val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( - contentPadding = - PaddingValues( - start = 0.dp, - top = 0.dp, - end = 0.dp, - bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), - ), + contentPadding = PaddingValues( + start = 0.dp, + top = 0.dp, + end = 0.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), ) { item { val startingRadioText = stringResource(R.string.starting_radio) NewActionGrid( - actions = - listOfNotNull( - if (!isListenTogetherGuest) { - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.radio), - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.start_radio), - onClick = { - Toast.makeText(context, startingRadioText, Toast.LENGTH_SHORT).show() - playerConnection.startRadioSeamlessly() - onDismiss() - }, - ) - } else { - null - }, - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.playlist_add), - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.add_to_playlist), - onClick = { showChoosePlaylistDialog = true }, - ), + actions = listOfNotNull( + if (!isListenTogetherGuest) { NewAction( icon = { Icon( - painter = painterResource(R.drawable.link), + painter = painterResource(R.drawable.radio), contentDescription = null, modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, - text = stringResource(R.string.copy_link), + text = stringResource(R.string.start_radio), onClick = { - val clipboard = - context.getSystemService( - android.content.Context.CLIPBOARD_SERVICE, - ) as android.content.ClipboardManager - val clip = - android.content.ClipData.newPlainText( - "Song Link", - "https://music.youtube.com/watch?v=${mediaMetadata.id}", - ) - clipboard.setPrimaryClip(clip) - android.widget.Toast - .makeText(context, R.string.link_copied, android.widget.Toast.LENGTH_SHORT) - .show() + Toast.makeText(context, startingRadioText, Toast.LENGTH_SHORT).show() + playerConnection.startRadioSeamlessly() onDismiss() - }, - ), + } + ) + } else null, + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.add_to_playlist), + onClick = { showChoosePlaylistDialog = true } ), + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.link), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.copy_link), + onClick = { + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("Song Link", "https://music.youtube.com/watch?v=${mediaMetadata.id}") + clipboard.setPrimaryClip(clip) + android.widget.Toast.makeText(context, R.string.link_copied, android.widget.Toast.LENGTH_SHORT).show() + onDismiss() + } + ) + ), columns = if (isListenTogetherGuest) 2 else 3, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) ) } @@ -360,106 +377,97 @@ fun PlayerMenu( val isPodcast = mediaMetadata.album?.let { !it.id.startsWith("MPREb_") } ?: false Material3MenuGroup( - items = - buildList { - // Don't show "View Artist" for podcasts - only show "View Podcast" - if (artists.isNotEmpty() && !isPodcast) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.view_artist)) }, - description = { - Text( - text = mediaMetadata.artists.joinToString { it.name }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - icon = { - Icon( - painter = painterResource(R.drawable.artist), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { - if (mediaMetadata.artists.size == 1) { - navController.navigate("artist/${mediaMetadata.artists[0].id}") - playerBottomSheetState.collapseSoft() - onDismiss() - } else { - showSelectArtistDialog = true - } - }, - ), - ) - } - if (mediaMetadata.album != null) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(if (isPodcast) R.string.view_podcast else R.string.view_album)) }, - description = { - Text( - text = mediaMetadata.album.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - icon = { - Icon( - painter = painterResource(if (isPodcast) R.drawable.mic else R.drawable.album), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { - if (isPodcast) { - navController.navigate("online_podcast/${mediaMetadata.album.id}") - } else { - navController.navigate("album/${mediaMetadata.album.id}") - } + items = buildList { + // Don't show "View Artist" for podcasts - only show "View Podcast" + if (artists.isNotEmpty() && !isPodcast) { + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.view_artist)) }, + description = { + Text( + text = mediaMetadata.artists.joinToString { it.name }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + icon = { + Icon( + painter = painterResource(R.drawable.artist), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + if (mediaMetadata.artists.size == 1) { + navController.navigate("artist/${mediaMetadata.artists[0].id}") playerBottomSheetState.collapseSoft() onDismiss() - }, - ), + } else { + showSelectArtistDialog = true + } + } ) - } - // Add to Library option - val isInLibrary = librarySong?.song?.inLibrary != null + ) + } + if (mediaMetadata.album != null) { add( Material3MenuItemData( - title = { + title = { Text(text = stringResource(if (isPodcast) R.string.view_podcast else R.string.view_album)) }, + description = { Text( - text = - stringResource( - if (isInLibrary) { - R.string.remove_from_library - } else { - R.string.add_to_library - }, - ), + text = mediaMetadata.album.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) }, icon = { Icon( - painter = - painterResource( - if (isInLibrary) { - R.drawable.library_add_check - } else { - R.drawable.library_add - }, - ), + painter = painterResource(if (isPodcast) R.drawable.mic else R.drawable.album), contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) }, onClick = { - playerConnection.toggleLibrary() + if (isPodcast) { + navController.navigate("online_podcast/${mediaMetadata.album.id}") + } else { + navController.navigate("album/${mediaMetadata.album.id}") + } + playerBottomSheetState.collapseSoft() onDismiss() - }, - ), + } + ) ) - }, + } + // Add to Library option + val isInLibrary = librarySong?.song?.inLibrary != null + add( + Material3MenuItemData( + title = { + Text( + text = stringResource( + if (isInLibrary) R.string.remove_from_library + else R.string.add_to_library + ) + ) + }, + icon = { + Icon( + painter = painterResource( + if (isInLibrary) R.drawable.library_add_check + else R.drawable.library_add + ), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + playerConnection.toggleLibrary() + onDismiss() + } + ) + ) + } ) } @@ -467,85 +475,124 @@ fun PlayerMenu( item { Material3MenuGroup( - items = - listOf( - when (download?.state) { - Download.STATE_COMPLETED -> { - Material3MenuItemData( - title = { - Text( - text = stringResource(R.string.remove_download), - ) - }, - icon = { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { + items = buildList { + when (download?.state) { + Download.STATE_COMPLETED -> { + add(Material3MenuItemData( + title = { + Text( + text = stringResource(R.string.remove_download) + ) + }, + icon = { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + timber.log.Timber.tag("PlayerMenu").d("Remove download clicked for: ${mediaMetadata.id}") + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false, + ) + } + )) + // Swap download option + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.swap_download)) }, + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + timber.log.Timber.tag("PlayerMenu").d("Swap download clicked for: ${mediaMetadata.id}") + showDownloadFormatDialog = true + isLoadingFormats = true + coroutineScope.launch(Dispatchers.IO) { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, mediaMetadata.id, false, ) - }, - ) - } + val result = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(mediaMetadata.id) + result.onSuccess { formats -> + timber.log.Timber.tag("PlayerMenu").d("Formats loaded: ${formats.size}") + availableFormats = formats + isLoadingFormats = false + }.onFailure { error -> + timber.log.Timber.tag("PlayerMenu").e(error, "Failed to load formats") + availableFormats = emptyList() + isLoadingFormats = false + } + } + } + )) + } - Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.downloading)) }, - icon = { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - }, - onClick = { - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - mediaMetadata.id, - false, - ) - }, - ) - } + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.downloading)) }, + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + onClick = { + timber.log.Timber.tag("PlayerMenu").d("Cancel download clicked for: ${mediaMetadata.id}") + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false, + ) + } + )) + } - else -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.action_download)) }, - icon = { - Icon( - painter = painterResource(R.drawable.download), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { + else -> { + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.action_download)) }, + icon = { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + timber.log.Timber.tag("PlayerMenu").d("Download clicked for: ${mediaMetadata.id}") + showDownloadFormatDialog = true + isLoadingFormats = true + coroutineScope.launch(Dispatchers.IO) { + timber.log.Timber.tag("PlayerMenu").d("Fetching formats...") database.transaction { insert(mediaMetadata) } - val downloadRequest = - DownloadRequest - .Builder(mediaMetadata.id, mediaMetadata.id.toUri()) - .setCustomCacheKey(mediaMetadata.id) - .setData(mediaMetadata.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false, - ) - }, - ) - } - }, - ), + val result = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(mediaMetadata.id) + result.onSuccess { formats -> + timber.log.Timber.tag("PlayerMenu").d("Formats loaded: ${formats.size}") + availableFormats = formats + isLoadingFormats = false + }.onFailure { error -> + timber.log.Timber.tag("PlayerMenu").e(error, "Failed to load formats") + availableFormats = emptyList() + isLoadingFormats = false + } + } + } + )) + } + } + } ) } @@ -553,60 +600,58 @@ fun PlayerMenu( item { Material3MenuGroup( - items = - buildList { + items = buildList { + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.listen_together)) }, + icon = { + // Show a small badge when there are pending suggestions + Box { + Icon( + painter = painterResource(R.drawable.group), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + if (pendingSuggestions.isNotEmpty()) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .offset(x = 8.dp, y = (-6).dp) + .align(Alignment.TopEnd) + ) { + Text( + text = pendingSuggestions.size.toString(), + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + }, + onClick = { showListenTogetherDialog = true } + ) + ) + if (isListenTogetherGuest) { add( Material3MenuItemData( - title = { Text(text = stringResource(R.string.listen_together)) }, + title = { Text(text = stringResource(R.string.resync)) }, icon = { - // Show a small badge when there are pending suggestions - Box { - Icon( - painter = painterResource(R.drawable.group), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - if (pendingSuggestions.isNotEmpty()) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primary, - modifier = - Modifier - .offset(x = 8.dp, y = (-6).dp) - .align(Alignment.TopEnd), - ) { - Text( - text = pendingSuggestions.size.toString(), - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - ) - } - } - } + Icon( + painter = painterResource(R.drawable.replay), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) }, - onClick = { showListenTogetherDialog = true }, - ), - ) - if (isListenTogetherGuest) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.resync)) }, - icon = { - Icon( - painter = painterResource(R.drawable.replay), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { - listenTogetherManager.requestSync() - onDismiss() - }, - ), + onClick = { + listenTogetherManager.requestSync() + onDismiss() + } ) - } - }, + ) + } + } ) } @@ -614,62 +659,61 @@ fun PlayerMenu( item { Material3MenuGroup( - items = - buildList { + items = buildList { + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.details)) }, + description = { Text(text = stringResource(R.string.details_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + onShowDetailsDialog() + onDismiss() + } + ) + ) + + if (isQueueTrigger != true) { add( Material3MenuItemData( - title = { Text(text = stringResource(R.string.details)) }, - description = { Text(text = stringResource(R.string.details_desc)) }, + title = { Text(text = stringResource(R.string.equalizer)) }, + description = { Text(text = stringResource(R.string.equalizer_desc)) }, icon = { Icon( - painter = painterResource(R.drawable.info), + painter = painterResource(R.drawable.equalizer), contentDescription = null, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) }, onClick = { - onShowDetailsDialog() + navController.navigate("equalizer") onDismiss() - }, - ), - ) - - if (isQueueTrigger != true) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.equalizer)) }, - description = { Text(text = stringResource(R.string.equalizer_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.equalizer), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { - navController.navigate("equalizer") - onDismiss() - }, - ), + } ) - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.advanced)) }, - description = { Text(text = stringResource(R.string.advanced_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.tune), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - onClick = { - showPitchTempoDialog = true - }, - ), + ) + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.advanced)) }, + description = { Text(text = stringResource(R.string.advanced_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.tune), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + onClick = { + showPitchTempoDialog = true + } ) - } - }, + ) + } + } ) } } @@ -802,52 +846,49 @@ fun ValueAdjuster( fun ListenTogetherDialog( visible: Boolean, mediaMetadata: MediaMetadata?, - onDismiss: () -> Unit, + onDismiss: () -> Unit ) { if (!visible) return - + val context = LocalContext.current val listenTogetherManager = com.metrolist.music.LocalListenTogetherManager.current - val joiningRoomTemplate = stringResource(R.string.joining_room) - + // Handle case where manager is not available if (listenTogetherManager == null) { ListDialog(onDismiss = onDismiss) { item { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp), + modifier = Modifier.size(48.dp) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.listen_together), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.listen_together_not_configured), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onDismiss, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) ) { Text(stringResource(android.R.string.ok)) } @@ -856,13 +897,13 @@ fun ListenTogetherDialog( } return } - + val connectionState by listenTogetherManager.connectionState.collectAsState() val roomState by listenTogetherManager.roomState.collectAsState() val userId by listenTogetherManager.userId.collectAsState() val pendingJoinRequests by listenTogetherManager.pendingJoinRequests.collectAsState() val pendingSuggestions by listenTogetherManager.pendingSuggestions.collectAsState() - + // Load saved username var savedUsername by rememberPreference(com.metrolist.music.constants.ListenTogetherUsernameKey, "") var roomCodeInput by rememberSaveable { mutableStateOf("") } @@ -872,11 +913,11 @@ fun ListenTogetherDialog( var isCreatingRoom by rememberSaveable { mutableStateOf(false) } var isJoiningRoom by rememberSaveable { mutableStateOf(false) } var joinErrorMessage by rememberSaveable { mutableStateOf(null) } - + // User action menu state var selectedUserForMenu by rememberSaveable { mutableStateOf(null) } var selectedUsername by rememberSaveable { mutableStateOf(null) } - + // Localized helper strings val waitingForApprovalText = stringResource(R.string.waiting_for_approval) val invalidRoomCodeText = stringResource(R.string.invalid_room_code) @@ -888,22 +929,21 @@ fun ListenTogetherDialog( onDismiss = { selectedUserForMenu = null selectedUsername = null - }, + } ) { item { Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, + horizontalArrangement = Arrangement.Start ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(40.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column { @@ -911,45 +951,44 @@ fun ListenTogetherDialog( text = stringResource(R.string.manage_user), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) Text( text = selectedUsername ?: "", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } - + item { Spacer(modifier = Modifier.height(12.dp)) } - + // Kick button item { Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clickable { - selectedUserForMenu?.let { - listenTogetherManager.kickUser(it, "Removed by host") - } - selectedUserForMenu = null - selectedUsername = null - }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clickable { + selectedUserForMenu?.let { + listenTogetherManager.kickUser(it, "Removed by host") + } + selectedUserForMenu = null + selectedUsername = null + }, shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.errorContainer, + color = MaterialTheme.colorScheme.errorContainer ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(16.dp) ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -957,49 +996,48 @@ fun ListenTogetherDialog( text = stringResource(R.string.kick_user), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.error, + color = MaterialTheme.colorScheme.error ) Text( text = stringResource(R.string.kick_user_desc), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } - + item { Spacer(modifier = Modifier.height(8.dp)) } - + // Permanently kick button item { Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clickable { - selectedUserForMenu?.let { userId -> - selectedUsername?.let { username -> - listenTogetherManager.blockUser(username) - listenTogetherManager.kickUser(userId, R.string.user_blocked_by_host.toString()) - } + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clickable { + selectedUserForMenu?.let { userId -> + selectedUsername?.let { username -> + listenTogetherManager.blockUser(username) + listenTogetherManager.kickUser(userId, R.string.user_blocked_by_host.toString()) } - selectedUserForMenu = null - selectedUsername = null - }, + } + selectedUserForMenu = null + selectedUsername = null + }, shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.surfaceVariant ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(16.dp) ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -1007,46 +1045,45 @@ fun ListenTogetherDialog( text = stringResource(R.string.permanently_kick_user), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurface ) Text( text = stringResource(R.string.permanently_kick_user_desc), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } - + item { Spacer(modifier = Modifier.height(8.dp)) } - + // Transfer ownership button item { Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .clickable { - selectedUserForMenu?.let { - listenTogetherManager.transferHost(it) - } - selectedUserForMenu = null - selectedUsername = null - }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clickable { + selectedUserForMenu?.let { + listenTogetherManager.transferHost(it) + } + selectedUserForMenu = null + selectedUsername = null + }, shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.primaryContainer ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(16.dp) ) { Icon( painter = painterResource(R.drawable.crown), contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -1054,18 +1091,18 @@ fun ListenTogetherDialog( text = stringResource(R.string.transfer_ownership), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) Text( text = stringResource(R.string.transfer_ownership_desc), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } - + item { Spacer(modifier = Modifier.height(16.dp)) } } return @@ -1084,12 +1121,11 @@ fun ListenTogetherDialog( when (event) { is ListenTogetherEvent.JoinRejected -> { val reason = event.reason - joinErrorMessage = - when { - reason.isNullOrBlank() -> joinRequestDeniedText - reason.contains("invalid", ignoreCase = true) == true -> invalidRoomCodeText - else -> "$joinRequestDeniedText: $reason" - } + joinErrorMessage = when { + reason.isNullOrBlank() -> joinRequestDeniedText + reason.contains("invalid", ignoreCase = true) == true -> invalidRoomCodeText + else -> "$joinRequestDeniedText: $reason" + } isJoiningRoom = false isCreatingRoom = false } @@ -1115,122 +1151,113 @@ fun ListenTogetherDialog( // Check if already in a room val isInRoom = listenTogetherManager.isInRoom val isHost = roomState?.hostId == userId - + ListDialog(onDismiss = onDismiss) { // Header - Icon on left, text left-aligned item { Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, + horizontalArrangement = Arrangement.Start ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(40.dp) ) Spacer(modifier = Modifier.width(16.dp)) Text( - text = - if (isInRoom) { - if (isHost) stringResource(R.string.hosting_room) else stringResource(R.string.in_room) - } else { - stringResource(R.string.listen_together) - }, + text = if (isInRoom) { + if (isHost) stringResource(R.string.hosting_room) else stringResource(R.string.in_room) + } else { + stringResource(R.string.listen_together) + }, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) } } - + // Connection status item { Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), - color = - when (connectionState) { - ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) - ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f) - ConnectionState.ERROR -> MaterialTheme.colorScheme.error.copy(alpha = 0.15f) - ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceVariant - }, + color = when (connectionState) { + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f) + ConnectionState.ERROR -> MaterialTheme.colorScheme.error.copy(alpha = 0.15f) + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceVariant + } ) { Column( modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.Center ) { Box( - modifier = - Modifier - .size(10.dp) - .background( - color = - when (connectionState) { - ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary - ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary - ConnectionState.ERROR -> MaterialTheme.colorScheme.error - ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline - }, - shape = RoundedCornerShape(50), - ), + modifier = Modifier + .size(10.dp) + .background( + color = when (connectionState) { + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary + ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary + ConnectionState.ERROR -> MaterialTheme.colorScheme.error + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline + }, + shape = RoundedCornerShape(50) + ) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = - when (connectionState) { - ConnectionState.CONNECTED -> stringResource(R.string.listen_together_connected) - ConnectionState.CONNECTING -> stringResource(R.string.listen_together_connecting) - ConnectionState.RECONNECTING -> stringResource(R.string.listen_together_reconnecting) - ConnectionState.ERROR -> stringResource(R.string.listen_together_error) - ConnectionState.DISCONNECTED -> stringResource(R.string.listen_together_disconnected) - }, + text = when (connectionState) { + ConnectionState.CONNECTED -> stringResource(R.string.listen_together_connected) + ConnectionState.CONNECTING -> stringResource(R.string.listen_together_connecting) + ConnectionState.RECONNECTING -> stringResource(R.string.listen_together_reconnecting) + ConnectionState.ERROR -> stringResource(R.string.listen_together_error) + ConnectionState.DISCONNECTED -> stringResource(R.string.listen_together_disconnected) + }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = - when (connectionState) { - ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary - ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary - ConnectionState.ERROR -> MaterialTheme.colorScheme.error - ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant - }, + color = when (connectionState) { + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary + ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary + ConnectionState.ERROR -> MaterialTheme.colorScheme.error + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant + } ) } - + if (connectionState == ConnectionState.CONNECTING || connectionState == ConnectionState.RECONNECTING) { Spacer(modifier = Modifier.height(12.dp)) LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) } - + Spacer(modifier = Modifier.height(12.dp)) - + Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { if (connectionState == ConnectionState.DISCONNECTED || connectionState == ConnectionState.ERROR) { Button( onClick = { listenTogetherManager.connect() }, modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) ) { Text(stringResource(R.string.connect), fontWeight = FontWeight.SemiBold) } @@ -1238,16 +1265,15 @@ fun ListenTogetherDialog( Button( onClick = { listenTogetherManager.disconnect() }, modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) ) { Text(stringResource(R.string.disconnect), fontWeight = FontWeight.SemiBold) } FilledTonalButton( onClick = { listenTogetherManager.forceReconnect() }, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { Text("Reconnect", fontWeight = FontWeight.SemiBold) } @@ -1256,9 +1282,9 @@ fun ListenTogetherDialog( } } } - + item { Spacer(modifier = Modifier.height(12.dp)) } - + if (connectionState == ConnectionState.CONNECTED && !isInRoom) { item { Text( @@ -1266,7 +1292,7 @@ fun ListenTogetherDialog( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp), - textAlign = TextAlign.Center, + textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(12.dp)) } @@ -1277,60 +1303,55 @@ fun ListenTogetherDialog( roomState?.let { room -> item { Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) ) { Column( modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.room_code), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.Center ) { Text( text = room.roomCode, style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, - letterSpacing = 6.sp, + letterSpacing = 6.sp ) } if (isHost) { Spacer(modifier = Modifier.height(12.dp)) - val inviteLink = - remember(room.roomCode) { - "https://metrolist.meowery.eu/listen?code=${room.roomCode}" - } + val inviteLink = remember(room.roomCode) { + "https://metrolist.meowery.eu/listen?code=${room.roomCode}" + } Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.Center ) { FilledTonalButton( onClick = { - val clipboard = - context.getSystemService( - Context.CLIPBOARD_SERVICE, - ) as android.content.ClipboardManager + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("Listen Together Link", inviteLink) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - }, + } ) { Icon( painter = painterResource(R.drawable.link), contentDescription = stringResource(R.string.copy_link), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.copy_link)) @@ -1340,19 +1361,16 @@ fun ListenTogetherDialog( FilledTonalButton( onClick = { - val clipboard = - context.getSystemService( - Context.CLIPBOARD_SERVICE, - ) as android.content.ClipboardManager + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("Room Code", room.roomCode) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() - }, + } ) { Icon( painter = painterResource(R.drawable.content_copy), contentDescription = stringResource(R.string.copy_code), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.copy_code)) @@ -1362,142 +1380,135 @@ fun ListenTogetherDialog( } } } - + item { Spacer(modifier = Modifier.height(16.dp)) } - + // Connected users - horizontal layout val connectedUsers = room.users.filter { it.isConnected } - + item { Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) ) { Text( text = stringResource(R.string.connected_users, connectedUsers.size), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 12.dp), + modifier = Modifier.padding(bottom = 12.dp) ) - + // Horizontal scrollable row for users Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { connectedUsers.forEach { user -> // User avatar card Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .width(72.dp) - .clickable( - enabled = isHost && user.userId != userId, - onClick = { - selectedUserForMenu = user.userId - selectedUsername = user.username - }, - ), + modifier = Modifier + .width(72.dp) + .clickable( + enabled = isHost && user.userId != userId, + onClick = { + selectedUserForMenu = user.userId + selectedUsername = user.username + } + ) ) { // Circular avatar Box( - contentAlignment = Alignment.Center, + contentAlignment = Alignment.Center ) { Surface( modifier = Modifier.size(52.dp), shape = RoundedCornerShape(50), - color = - if (user.isHost) { - MaterialTheme.colorScheme.primary - } else if (user.userId == userId) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.surfaceVariant - }, + color = if (user.isHost) { + MaterialTheme.colorScheme.primary + } else if (user.userId == userId) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.surfaceVariant + } ) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize() ) { Text( text = user.username.take(1).uppercase(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = - if (user.isHost) { - MaterialTheme.colorScheme.onPrimary - } else if (user.userId == userId) { - MaterialTheme.colorScheme.onSecondary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = if (user.isHost) { + MaterialTheme.colorScheme.onPrimary + } else if (user.userId == userId) { + MaterialTheme.colorScheme.onSecondary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } ) } } - + // Host/You badge if (user.isHost || user.userId == userId) { Surface( - modifier = - Modifier - .align(Alignment.BottomEnd) - .offset(x = 4.dp, y = 4.dp) - .size(18.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 4.dp, y = 4.dp) + .size(18.dp), shape = RoundedCornerShape(50), - color = if (user.isHost) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + color = if (user.isHost) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary ) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize() ) { Icon( - painter = - painterResource( - if (user.isHost) R.drawable.crown else R.drawable.person, - ), + painter = painterResource( + if (user.isHost) R.drawable.crown else R.drawable.person + ), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(12.dp), + modifier = Modifier.size(12.dp) ) } } } } - + Spacer(modifier = Modifier.height(6.dp)) - + // Username Text( text = user.username, style = MaterialTheme.typography.labelMedium, fontWeight = if (user.userId == userId) FontWeight.Bold else FontWeight.Medium, - color = - if (user.isHost) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, + color = if (user.isHost) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, + textAlign = TextAlign.Center ) - + // Role label if (user.isHost) { Text( text = stringResource(R.string.host_label), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) ) } else if (user.userId == userId) { Text( text = stringResource(R.string.you_label), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f) ) } } @@ -1505,7 +1516,7 @@ fun ListenTogetherDialog( } } } - + // Pending join requests (host only) if (isHost && pendingJoinRequests.isNotEmpty()) { item { @@ -1517,44 +1528,43 @@ fun ListenTogetherDialog( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(8.dp)) } - + items(pendingJoinRequests) { request -> Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(12.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { Surface( modifier = Modifier.size(36.dp), shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.secondary ) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize() ) { Text( text = request.username.take(1).uppercase(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondary, + color = MaterialTheme.colorScheme.onSecondary ) } } @@ -1562,29 +1572,29 @@ fun ListenTogetherDialog( text = request.username, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurface ) } - + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton( - onClick = { listenTogetherManager.approveJoin(request.userId) }, + onClick = { listenTogetherManager.approveJoin(request.userId) } ) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.approve), tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } IconButton( - onClick = { listenTogetherManager.rejectJoin(request.userId, "Rejected by host") }, + onClick = { listenTogetherManager.rejectJoin(request.userId, "Rejected by host") } ) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.reject), tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } } @@ -1604,35 +1614,34 @@ fun ListenTogetherDialog( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(8.dp)) } items(pendingSuggestions) { suggestion -> Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(12.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) Column(modifier = Modifier.weight(1f)) { Text( @@ -1641,37 +1650,37 @@ fun ListenTogetherDialog( fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis ) Text( text = suggestion.fromUsername, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis ) } } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton( - onClick = { listenTogetherManager.approveSuggestion(suggestion.suggestionId) }, + onClick = { listenTogetherManager.approveSuggestion(suggestion.suggestionId) } ) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.approve), tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } IconButton( - onClick = { listenTogetherManager.rejectSuggestion(suggestion.suggestionId, "Rejected by host") }, + onClick = { listenTogetherManager.rejectSuggestion(suggestion.suggestionId, "Rejected by host") } ) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.reject), tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(24.dp) ) } } @@ -1679,24 +1688,23 @@ fun ListenTogetherDialog( } } } - + // Leave room button item { Spacer(modifier = Modifier.height(20.dp)) Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { TextButton( onClick = onDismiss, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) ) { Text( stringResource(R.string.cancel), - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Medium ) } Button( @@ -1705,15 +1713,14 @@ fun ListenTogetherDialog( onDismiss() }, modifier = Modifier.weight(1f), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) ) { Icon( painter = painterResource(R.drawable.logout), contentDescription = null, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(18.dp) ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.leave_room), fontWeight = FontWeight.SemiBold) @@ -1726,24 +1733,23 @@ fun ListenTogetherDialog( // Join/Create room section item { Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) { Column( modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = stringResource(R.string.listen_together_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + textAlign = TextAlign.Center ) - + OutlinedTextField( value = usernameInput, onValueChange = { usernameInput = it }, @@ -1753,7 +1759,7 @@ fun ListenTogetherDialog( Icon( painterResource(R.drawable.person), null, - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.primary ) }, trailingIcon = { @@ -1765,24 +1771,23 @@ fun ListenTogetherDialog( }, singleLine = true, shape = RoundedCornerShape(12.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedLabelColor = MaterialTheme.colorScheme.primary, - ), - modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() ) - + HorizontalDivider() - + Text( text = stringResource(R.string.join_existing_room), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) - + OutlinedTextField( value = roomCodeInput, onValueChange = { roomCodeInput = it.uppercase().filter { c -> c.isLetterOrDigit() }.take(8) }, @@ -1792,25 +1797,24 @@ fun ListenTogetherDialog( Text( text = "${roomCodeInput.length}/8", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) }, leadingIcon = { Icon( painterResource(R.drawable.token), null, - tint = MaterialTheme.colorScheme.primary, + tint = MaterialTheme.colorScheme.primary ) }, singleLine = true, shape = RoundedCornerShape(12.dp), - colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedLabelColor = MaterialTheme.colorScheme.primary, - ), - modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedLabelColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() ) // Status messages @@ -1818,46 +1822,46 @@ fun ListenTogetherDialog( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(8.dp)) Text( text = waitingForApprovalText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Medium ) } } - + joinErrorMessage?.let { msg -> Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(12.dp) ) { Icon( painterResource(R.drawable.error), contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.error, + tint = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.width(8.dp)) Text( text = msg, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Medium ) } } @@ -1865,20 +1869,19 @@ fun ListenTogetherDialog( } } } - + // Action buttons item { Spacer(modifier = Modifier.height(20.dp)) Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // Create Room button (left side) Button( @@ -1899,20 +1902,19 @@ fun ListenTogetherDialog( }, modifier = Modifier.weight(1f), enabled = (usernameInput.trim().isNotBlank() || savedUsername.isNotBlank()), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) ) { Icon( painter = painterResource(R.drawable.add), contentDescription = null, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(18.dp) ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.create_room), fontWeight = FontWeight.SemiBold) } - + // Join Room button (right side - only visible when room code is complete) if (roomCodeInput.length == 8) { Button( @@ -1921,12 +1923,11 @@ fun ListenTogetherDialog( val finalUsername = username.trim() if (finalUsername.isNotBlank()) { savedUsername = finalUsername - Toast - .makeText( - context, - String.format(joiningRoomTemplate, roomCodeInput), - Toast.LENGTH_SHORT, - ).show() + Toast.makeText( + context, + context.getString(R.string.joining_room, roomCodeInput), + Toast.LENGTH_SHORT + ).show() isJoiningRoom = true isCreatingRoom = false joinErrorMessage = null @@ -1938,30 +1939,29 @@ fun ListenTogetherDialog( }, modifier = Modifier.weight(1f), enabled = (usernameInput.trim().isNotBlank() || savedUsername.isNotBlank()), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary, - ), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) ) { Icon( painter = painterResource(R.drawable.login), contentDescription = null, - modifier = Modifier.size(18.dp), + modifier = Modifier.size(18.dp) ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.join_room), fontWeight = FontWeight.SemiBold) } } } - + TextButton( onClick = onDismiss, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { Text( stringResource(R.string.cancel), fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/PlaylistMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/PlaylistMenu.kt index be10c87eb7..0f8d840463 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/PlaylistMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/PlaylistMenu.kt @@ -7,7 +7,6 @@ package com.metrolist.music.ui.menu import android.content.Intent import android.content.res.Configuration -import android.widget.Toast import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -52,9 +51,9 @@ import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.db.entities.Playlist +import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.db.entities.Song -import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue @@ -66,14 +65,11 @@ import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.TextFieldDialog -import com.metrolist.music.ui.menu.ExportDialog -import com.metrolist.music.utils.PlaylistExporter -import com.metrolist.music.utils.getExportFileUri -import com.metrolist.music.utils.saveToPublicDocuments import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import java.time.LocalDateTime @Composable @@ -116,8 +112,6 @@ fun PlaylistMenu( val isPinned by database.speedDialDao.isPinned(playlist.id).collectAsState(initial = false) - var showExportDialog by remember { mutableStateOf(false) } - LaunchedEffect(songs) { if (songs.isEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> @@ -126,8 +120,8 @@ fun PlaylistMenu( Download.STATE_COMPLETED } else if (songs.all { downloads[it.id]?.state == Download.STATE_QUEUED || - downloads[it.id]?.state == Download.STATE_DOWNLOADING || - downloads[it.id]?.state == Download.STATE_COMPLETED + downloads[it.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.id]?.state == Download.STATE_COMPLETED } ) { Download.STATE_DOWNLOADING @@ -147,18 +141,18 @@ fun PlaylistMenu( title = { Text(text = stringResource(R.string.edit_playlist)) }, onDismiss = { showEditDialog = false }, initialTextFieldValue = - TextFieldValue( - playlist.playlist.name, - TextRange(playlist.playlist.name.length), - ), + TextFieldValue( + playlist.playlist.name, + TextRange(playlist.playlist.name.length), + ), onDone = { name -> onDismiss() database.query { update( playlist.playlist.copy( name = name, - lastUpdateTime = LocalDateTime.now(), - ), + lastUpdateTime = LocalDateTime.now() + ) ) } coroutineScope.launch(Dispatchers.IO) { @@ -172,16 +166,49 @@ fun PlaylistMenu( mutableStateOf(false) } + // Download format picker state + var showDownloadFormatDialog by remember { mutableStateOf(false) } + var availableFormats by remember { mutableStateOf>(emptyList()) } + var isLoadingFormats by remember { mutableStateOf(false) } + + if (showDownloadFormatDialog) { + com.metrolist.music.ui.component.DownloadFormatDialog( + isLoading = isLoadingFormats, + formats = availableFormats, + onFormatSelected = { format -> + Timber.tag("PlaylistMenu").d("Format selected for playlist: ${format.displayName} (itag=${format.itag})") + showDownloadFormatDialog = false + // Download all songs with selected format + songs.forEach { song -> + downloadUtil.setTargetItag(song.id, format.itag) + val downloadRequest = DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onDismiss = { + showDownloadFormatDialog = false + } + ) + } + if (showRemoveDownloadDialog) { DefaultDialog( onDismiss = { showRemoveDownloadDialog = false }, content = { Text( - text = - stringResource( - R.string.remove_download_playlist_confirm, - playlist.playlist.name, - ), + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist.playlist.name + ), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 18.dp), ) @@ -225,14 +252,14 @@ fun PlaylistMenu( Text( text = stringResource(R.string.delete_playlist_confirm, playlist.playlist.name), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 18.dp), + modifier = Modifier.padding(horizontal = 18.dp) ) }, buttons = { TextButton( onClick = { showDeletePlaylistDialog = false - }, + } ) { Text(text = stringResource(android.R.string.cancel)) } @@ -254,11 +281,11 @@ fun PlaylistMenu( coroutineScope.launch(Dispatchers.IO) { playlist.playlist.browseId?.let { YouTube.deletePlaylist(it) } } - }, + } ) { Text(text = stringResource(android.R.string.ok)) } - }, + } ) } @@ -271,28 +298,12 @@ fun PlaylistMenu( database.query { dbPlaylist?.playlist?.toggleLike()?.let { update(it) } } - }, + } ) { Icon( - painter = - painterResource( - if (dbPlaylist?.playlist?.bookmarkedAt != - null - ) { - R.drawable.favorite - } else { - R.drawable.favorite_border - }, - ), - tint = - if (dbPlaylist?.playlist?.bookmarkedAt != - null - ) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - }, - contentDescription = null, + painter = painterResource(if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null ) } } @@ -307,167 +318,158 @@ fun PlaylistMenu( val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( - contentPadding = - PaddingValues( - start = 0.dp, - top = 0.dp, - end = 0.dp, - bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), - ), + contentPadding = PaddingValues( + start = 0.dp, + top = 0.dp, + end = 0.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), ) { item { NewActionGrid( - actions = - listOfNotNull( - if (!isGuest) { - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.play), - onClick = { - onDismiss() - if (songs.isNotEmpty()) { - playerConnection.playQueue( - ListQueue( - title = playlist.playlist.name, - items = songs.map(Song::toMediaItem), - ), + actions = listOfNotNull( + if (!isGuest) { + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.play), + onClick = { + onDismiss() + if (songs.isNotEmpty()) { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map(Song::toMediaItem) ) - } - }, - ) - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - text = stringResource(R.string.shuffle), - onClick = { - onDismiss() - if (songs.isNotEmpty()) { - playerConnection.playQueue( - ListQueue( - title = playlist.playlist.name, - items = songs.shuffled().map(Song::toMediaItem), - ), - ) - } - }, - ) - } else { - null - }, + } + } + ) NewAction( icon = { Icon( - painter = painterResource(R.drawable.share), + painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, - text = stringResource(R.string.share), + text = stringResource(R.string.shuffle), onClick = { onDismiss() - val intent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "https://music.youtube.com/playlist?list=${dbPlaylist?.playlist?.browseId}", + if (songs.isNotEmpty()) { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map(Song::toMediaItem) ) - } - context.startActivity(Intent.createChooser(intent, null)) - }, - ), - ), + ) + } + } + ) + } else null, + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.share), + onClick = { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/playlist?list=${dbPlaylist?.playlist?.browseId}") + } + context.startActivity(Intent.createChooser(intent, null)) + } + ) + ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), - columns = if (isGuest) 1 else 3, + columns = if (isGuest) 1 else 3 ) } item { Material3MenuGroup( - items = - buildList { - if (!isGuest) { - playlist.playlist.browseId?.let { browseId -> - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.start_radio)) }, - description = { Text(text = stringResource(R.string.start_radio_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.radio), - contentDescription = null, - ) - }, - onClick = { - coroutineScope.launch(Dispatchers.IO) { - YouTube.playlist(browseId).getOrNull()?.playlist?.let { playlistItem -> - playlistItem.radioEndpoint?.let { radioEndpoint -> - withContext(Dispatchers.Main) { - playerConnection.playQueue(YouTubeQueue(radioEndpoint)) - } - } - } - } - onDismiss() - }, - ), - ) - } - } - if (!isGuest) { + items = buildList { + if (!isGuest) { + playlist.playlist.browseId?.let { browseId -> add( Material3MenuItemData( - title = { Text(text = stringResource(R.string.play_next)) }, - description = { Text(text = stringResource(R.string.play_next_desc)) }, + title = { Text(text = stringResource(R.string.start_radio)) }, + description = { Text(text = stringResource(R.string.start_radio_desc)) }, icon = { Icon( - painter = painterResource(R.drawable.playlist_play), + painter = painterResource(R.drawable.radio), contentDescription = null, ) }, onClick = { - coroutineScope.launch { - playerConnection.playNext(songs.map { it.toMediaItem() }) + coroutineScope.launch(Dispatchers.IO) { + YouTube.playlist(browseId).getOrNull()?.playlist?.let { playlistItem -> + playlistItem.radioEndpoint?.let { radioEndpoint -> + withContext(Dispatchers.Main) { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + } + } + } } onDismiss() - }, - ), + } + ) ) } - if (!isGuest) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.add_to_queue)) }, - description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.queue_music), - contentDescription = null, - ) - }, - onClick = { - onDismiss() - playerConnection.addToQueue(songs.map { it.toMediaItem() }) - }, - ), + } + if (!isGuest) { + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.play_next)) }, + description = { Text(text = stringResource(R.string.play_next_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.playlist_play), + contentDescription = null, + ) + }, + onClick = { + coroutineScope.launch { + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + onDismiss() + } ) - } - }, + ) + } + if (!isGuest) { + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.add_to_queue)) }, + description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + ) + }, + onClick = { + onDismiss() + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + ) + ) + } + } ) } @@ -475,256 +477,175 @@ fun PlaylistMenu( item { Material3MenuGroup( - items = - buildList { - if (editable && autoPlaylist != true && !isGuest) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.edit)) }, - description = { Text(text = stringResource(R.string.edit_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.edit), - contentDescription = null, - ) - }, - onClick = { - showEditDialog = true - }, - ), - ) - } + items = buildList { + if (editable && autoPlaylist != true && !isGuest) { add( Material3MenuItemData( - title = { - Text( - text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial", - ) - }, + title = { Text(text = stringResource(R.string.edit)) }, + description = { Text(text = stringResource(R.string.edit_desc)) }, icon = { Icon( - painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), + painter = painterResource(R.drawable.edit), contentDescription = null, ) }, onClick = { - coroutineScope.launch(Dispatchers.IO) { - if (isPinned) { - database.speedDialDao.delete(playlist.id) - } else { - database.speedDialDao.insert( - SpeedDialItem( - id = playlist.id, - title = playlist.playlist.name, - subtitle = null, - thumbnailUrl = playlist.thumbnails.firstOrNull(), - type = "LOCAL_PLAYLIST", - ), - ) - } - } - onDismiss() - }, - ), + showEditDialog = true + } + ) ) - if (downloadPlaylist != true) { - add( - when (downloadState) { - Download.STATE_COMPLETED -> { - Material3MenuItemData( - title = { - Text( - text = stringResource(R.string.remove_download), - ) - }, - icon = { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - ) - }, - onClick = { - showRemoveDownloadDialog = true - }, - ) - } - - Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.downloading)) }, - icon = { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - }, - onClick = { - showRemoveDownloadDialog = true - }, + } + add( + Material3MenuItemData( + title = { + Text( + text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial" + ) + }, + icon = { + Icon( + painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), + contentDescription = null, + ) + }, + onClick = { + coroutineScope.launch(Dispatchers.IO) { + if (isPinned) { + database.speedDialDao.delete(playlist.id) + } else { + database.speedDialDao.insert( + SpeedDialItem( + id = playlist.id, + title = playlist.playlist.name, + subtitle = null, + thumbnailUrl = playlist.thumbnails.firstOrNull(), + type = "LOCAL_PLAYLIST" + ) ) } + } + onDismiss() + } + ) + ) + if (downloadPlaylist != true) { + add( + when (downloadState) { + Download.STATE_COMPLETED -> { + Material3MenuItemData( + title = { + Text( + text = stringResource(R.string.remove_download) + ) + }, + icon = { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + }, + onClick = { + showRemoveDownloadDialog = true + } + ) + } + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + Material3MenuItemData( + title = { Text(text = stringResource(R.string.downloading)) }, + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + onClick = { + showRemoveDownloadDialog = true + } + ) + } + else -> { + Material3MenuItemData( + title = { Text(text = stringResource(R.string.action_download)) }, + description = { Text(text = stringResource(R.string.download_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + ) + }, + onClick = { + if (songs.isEmpty()) return@Material3MenuItemData + // Show format picker - fetch formats from first song + showDownloadFormatDialog = true + isLoadingFormats = true + availableFormats = emptyList() - else -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.action_download)) }, - description = { Text(text = stringResource(R.string.download_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.download), - contentDescription = null, - ) - }, - onClick = { - songs.forEach { song -> - val downloadRequest = - DownloadRequest - .Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false, - ) + coroutineScope.launch(Dispatchers.IO) { + try { + val firstSong = songs.first() + val formats = com.metrolist.music.utils.YTPlayerUtils + .getAllAvailableAudioFormats(firstSong.id) + .getOrNull() ?: emptyList() + withContext(Dispatchers.Main) { + availableFormats = formats + isLoadingFormats = false + } + } catch (e: Exception) { + Timber.tag("PlaylistMenu").e(e, "Failed to fetch formats") + withContext(Dispatchers.Main) { + isLoadingFormats = false + showDownloadFormatDialog = false + } } - }, - ) - } + } + } + ) + } + } + ) + } + if (autoPlaylist != true && !isGuest) { + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.delete)) }, + description = { Text(text = stringResource(R.string.delete_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = null, + ) }, + onClick = { + showDeletePlaylistDialog = true + } ) - } - // Export playlist + ) + } + playlist.playlist.shareLink?.let { shareLink -> add( Material3MenuItemData( - title = { Text(text = stringResource(R.string.export_playlist)) }, + title = { Text(text = stringResource(R.string.share)) }, + description = { Text(text = stringResource(R.string.share_desc)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, - onClick = { showExportDialog = true }, - ), - ) - if (autoPlaylist != true && !isGuest) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.delete)) }, - description = { Text(text = stringResource(R.string.delete_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.delete), - contentDescription = null, - ) - }, - onClick = { - showDeletePlaylistDialog = true - }, - ), - ) - } - playlist.playlist.shareLink?.let { shareLink -> - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.share)) }, - description = { Text(text = stringResource(R.string.share_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.share), - contentDescription = null, - ) - }, - onClick = { - val intent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, shareLink) - } - context.startActivity(Intent.createChooser(intent, null)) - onDismiss() - }, - ), + onClick = { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } ) - } - }, - ) - } - } - - val exportPlaylistStr = stringResource(R.string.export_playlist) - - if (showExportDialog) { - ExportDialog( - onDismiss = { showExportDialog = false }, - onShare = { format -> - val playlistSongs = - songs.map { s -> - com.metrolist.music.db.entities.PlaylistSong( - map = - com.metrolist.music.db.entities.PlaylistSongMap( - songId = s.id, - playlistId = playlist.id, - position = 0, - ), - song = s, - ) - } - val result = - when (format) { - "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, playlist.playlist.name, playlistSongs) - "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, playlist.playlist.name, playlistSongs) - else -> Result.failure(IllegalArgumentException("Unknown format")) - } - result - .onSuccess { file -> - val uri = getExportFileUri(context, file) - val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(Intent.createChooser(shareIntent, exportPlaylistStr)) - }.onFailure { - Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() - } - showExportDialog = false - }, - onSave = { format -> - val playlistSongs = - songs.map { s -> - com.metrolist.music.db.entities.PlaylistSong( - map = - com.metrolist.music.db.entities.PlaylistSongMap( - songId = s.id, - playlistId = playlist.id, - position = 0, - ), - song = s, ) } - val export = - when (format) { - "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, playlist.playlist.name, playlistSongs) - "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, playlist.playlist.name, playlistSongs) - else -> Result.failure(IllegalArgumentException("Unknown format")) - } - export - .onSuccess { file -> - val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" - val save = saveToPublicDocuments(context, file, mimeType) - save - .onSuccess { Toast.makeText(context, R.string.export_success, Toast.LENGTH_SHORT).show() } - .onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } - }.onFailure { - Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() - } - showExportDialog = false - }, - ) + } + ) + } } } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt index 6a769c8dd4..bb1a94a78b 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt @@ -263,6 +263,40 @@ fun SongMenu( } } + // Download format picker state + var showDownloadFormatDialog by rememberSaveable { mutableStateOf(false) } + var availableFormats by remember { mutableStateOf>(emptyList()) } + var isLoadingFormats by remember { mutableStateOf(false) } + val downloadUtil = LocalDownloadUtil.current + + if (showDownloadFormatDialog) { + com.metrolist.music.ui.component.DownloadFormatDialog( + isLoading = isLoadingFormats, + formats = availableFormats, + onFormatSelected = { format -> + timber.log.Timber.tag("SongMenu").d("Format selected: ${format.displayName} (itag=${format.itag})") + showDownloadFormatDialog = false // Close format picker, keep menu open + // Set target itag and start download + downloadUtil.setTargetItag(song.id, format.itag) + val downloadRequest = + androidx.media3.exoplayer.offline.DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + androidx.media3.exoplayer.offline.DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + }, + onDismiss = { + showDownloadFormatDialog = false + } + ) + } + var showSelectArtistDialog by rememberSaveable { mutableStateOf(false) } @@ -827,10 +861,11 @@ fun SongMenu( item { Material3MenuGroup( - items = listOf( + items = buildList { when (download?.state) { Download.STATE_COMPLETED -> { - Material3MenuItemData( + // Remove download option + add(Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download) @@ -843,6 +878,7 @@ fun SongMenu( ) }, onClick = { + timber.log.Timber.tag("SongMenu").d("Remove download clicked for: ${song.id}") DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, @@ -850,10 +886,47 @@ fun SongMenu( false, ) } - ) + )) + // Swap download option (re-download with different format) + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.swap_download)) }, + description = { Text(text = stringResource(R.string.swap_download_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + }, + onClick = { + timber.log.Timber.tag("SongMenu").d("Swap download clicked for: ${song.id}") + // Show format picker for re-download + showDownloadFormatDialog = true + isLoadingFormats = true + coroutineScope.launch(Dispatchers.IO) { + timber.log.Timber.tag("SongMenu").d("Fetching formats for swap...") + // First remove existing download + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + val result = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(song.id) + result.onSuccess { formats -> + timber.log.Timber.tag("SongMenu").d("Formats loaded for swap: ${formats.size}") + availableFormats = formats + isLoadingFormats = false + }.onFailure { error -> + timber.log.Timber.tag("SongMenu").e(error, "Failed to load formats for swap") + availableFormats = emptyList() + isLoadingFormats = false + } + } + } + )) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { - Material3MenuItemData( + add(Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( @@ -862,6 +935,7 @@ fun SongMenu( ) }, onClick = { + timber.log.Timber.tag("SongMenu").d("Cancel download clicked for: ${song.id}") DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, @@ -869,10 +943,10 @@ fun SongMenu( false, ) } - ) + )) } else -> { - Material3MenuItemData( + add(Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { @@ -882,23 +956,28 @@ fun SongMenu( ) }, onClick = { - val downloadRequest = - DownloadRequest - .Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false, - ) + timber.log.Timber.tag("SongMenu").d("Download clicked for: ${song.id}") + // Show format picker dialog + showDownloadFormatDialog = true + isLoadingFormats = true + coroutineScope.launch(Dispatchers.IO) { + timber.log.Timber.tag("SongMenu").d("Fetching available formats...") + val result = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(song.id) + result.onSuccess { formats -> + timber.log.Timber.tag("SongMenu").d("Formats loaded: ${formats.size}") + availableFormats = formats + isLoadingFormats = false + }.onFailure { error -> + timber.log.Timber.tag("SongMenu").e(error, "Failed to load formats") + availableFormats = emptyList() + isLoadingFormats = false + } + } } - ) + )) } } - ) + } ) } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt index ffdee51234..4d066f5e61 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt @@ -8,7 +8,6 @@ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration -import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -68,9 +67,9 @@ import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.ThumbnailCornerRadius +import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap -import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata @@ -84,17 +83,14 @@ import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.utils.resize -import com.metrolist.music.utils.exportYouTubePlaylistAsCSV -import com.metrolist.music.utils.exportYouTubePlaylistAsM3U -import com.metrolist.music.utils.getExportFileUri import com.metrolist.music.utils.joinByBullet import com.metrolist.music.utils.makeTimeString -import com.metrolist.music.utils.saveToPublicDocuments import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber @OptIn(ExperimentalMaterial3Api::class) @SuppressLint("MutableCollectionMutableState") @@ -127,18 +123,12 @@ fun YouTubePlaylistMenu( AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, onGetSong = { targetPlaylist -> - val allSongs = - songs - .ifEmpty { - YouTube - .playlist(targetPlaylist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - }.map { - it.toMediaMetadata() - } + val allSongs = songs + .ifEmpty { + YouTube.playlist(targetPlaylist.id).completed().getOrNull()?.songs.orEmpty() + }.map { + it.toMediaMetadata() + } database.withTransaction { allSongs.forEach(::insert) } @@ -161,20 +151,18 @@ fun YouTubePlaylistMenu( val isCurrentlySaved = dbPlaylist?.playlist?.bookmarkedAt != null if (dbPlaylist?.playlist == null) { database.transaction { - val playlistEntity = - PlaylistEntity( - name = playlist.title, - browseId = playlist.id, - thumbnailUrl = playlist.thumbnail, - isEditable = playlist.isEditable, - remoteSongCount = - playlist.songCountText?.let { - Regex("""\d+""").find(it)?.value?.toIntOrNull() - }, - playEndpointParams = playlist.playEndpoint?.params, - shuffleEndpointParams = playlist.shuffleEndpoint?.params, - radioEndpointParams = playlist.radioEndpoint?.params, - ).toggleLike() + val playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + thumbnailUrl = playlist.thumbnail, + isEditable = playlist.isEditable, + remoteSongCount = playlist.songCountText?.let { + Regex("""\d+""").find(it)?.value?.toIntOrNull() + }, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint?.params, + radioEndpointParams = playlist.radioEndpoint?.params + ).toggleLike() insert(playlistEntity) } } else { @@ -188,70 +176,49 @@ fun YouTubePlaylistMenu( if (!isCurrentlySaved) { val playlistEntity = database.playlistByBrowseId(playlist.id).first()?.playlist if (playlistEntity != null) { - songs - .ifEmpty { - YouTube - .playlist(playlist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - }.map { it.toMediaMetadata() } + songs.ifEmpty { + YouTube.playlist(playlist.id).completed() + .getOrNull()?.songs.orEmpty() + }.map { it.toMediaMetadata() } .onEach { database.transaction { insert(it) } } .mapIndexed { index, song -> PlaylistSongMap( songId = song.id, playlistId = playlistEntity.id, position = index, - setVideoId = song.setVideoId, + setVideoId = song.setVideoId ) - }.forEach { database.transaction { insert(it) } } + } + .forEach { database.transaction { insert(it) } } } } if (playlist.isPodcast) { - YouTube - .savePodcast(playlist.id, !isCurrentlySaved) + YouTube.savePodcast(playlist.id, !isCurrentlySaved) .onSuccess { timber.log.Timber.d("[PODCAST_SAVE] savePodcast API success for ${playlist.id}") - }.onFailure { e -> + } + .onFailure { e -> timber.log.Timber.e(e, "[PODCAST_SAVE] savePodcast API failed for ${playlist.id}") withContext(Dispatchers.Main) { - android.widget.Toast - .makeText( - context, - if (isCurrentlySaved) R.string.error_podcast_unsubscribe else R.string.error_podcast_subscribe, - android.widget.Toast.LENGTH_SHORT, - ).show() + android.widget.Toast.makeText( + context, + if (isCurrentlySaved) R.string.error_podcast_unsubscribe else R.string.error_podcast_subscribe, + android.widget.Toast.LENGTH_SHORT + ).show() } } } } - }, + } ) { Icon( - painter = - painterResource( - if (dbPlaylist?.playlist?.bookmarkedAt != - null - ) { - R.drawable.favorite - } else { - R.drawable.favorite_border - }, - ), - tint = - if (dbPlaylist?.playlist?.bookmarkedAt != - null - ) { - MaterialTheme.colorScheme.error - } else { - LocalContentColor.current - }, - contentDescription = null, + painter = painterResource(if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null ) } } - }, + } ) HorizontalDivider() @@ -262,41 +229,37 @@ fun YouTubePlaylistMenu( if (songs.isEmpty()) return@LaunchedEffect downloadUtil.downloads.collect { downloads -> downloadState = - if (songs.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) { + if (songs.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) Download.STATE_COMPLETED - } else if (songs.all { - downloads[it.id]?.state == Download.STATE_QUEUED || - downloads[it.id]?.state == Download.STATE_DOWNLOADING || - downloads[it.id]?.state == Download.STATE_COMPLETED - } - ) { + else if (songs.all { + downloads[it.id]?.state == Download.STATE_QUEUED + || downloads[it.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.id]?.state == Download.STATE_COMPLETED + }) Download.STATE_DOWNLOADING - } else { + else Download.STATE_STOPPED - } } } var showRemoveDownloadDialog by remember { mutableStateOf(false) } - var showExportDialog by remember { mutableStateOf(false) } if (showRemoveDownloadDialog) { DefaultDialog( onDismiss = { showRemoveDownloadDialog = false }, content = { Text( - text = - stringResource( - R.string.remove_download_playlist_confirm, - playlist.title, - ), + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist.title + ), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 18.dp), + modifier = Modifier.padding(horizontal = 18.dp) ) }, buttons = { TextButton( - onClick = { showRemoveDownloadDialog = false }, + onClick = { showRemoveDownloadDialog = false } ) { Text(text = stringResource(android.R.string.cancel)) } @@ -308,39 +271,72 @@ fun YouTubePlaylistMenu( context, ExoDownloadService::class.java, song.id, - false, + false ) } - }, + } ) { Text(text = stringResource(android.R.string.ok)) } + } + ) + } + + // Download format picker state + var showDownloadFormatDialog by remember { mutableStateOf(false) } + var availableFormats by remember { mutableStateOf>(emptyList()) } + var isLoadingFormats by remember { mutableStateOf(false) } + + if (showDownloadFormatDialog) { + com.metrolist.music.ui.component.DownloadFormatDialog( + isLoading = isLoadingFormats, + formats = availableFormats, + onFormatSelected = { format -> + Timber.tag("YouTubePlaylistMenu").d("Format selected for playlist: ${format.displayName} (itag=${format.itag})") + showDownloadFormatDialog = false + // Insert songs to database first, then download with selected format + database.transaction { + songs.forEach { song -> + insert(song.toMediaMetadata()) + } + } + songs.forEach { song -> + downloadUtil.setTargetItag(song.id, format.itag) + val downloadRequest = DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } }, + onDismiss = { + showDownloadFormatDialog = false + } ) } ImportPlaylistDialog( isVisible = showImportPlaylistDialog, onGetSong = { - val allSongs = - songs - .ifEmpty { - YouTube - .playlist(playlist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - }.map { - it.toMediaMetadata() - } + val allSongs = songs + .ifEmpty { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + }.map { + it.toMediaMetadata() + } database.withTransaction { allSongs.forEach(::insert) } allSongs.map { it.id } }, playlistTitle = playlist.title, - onDismiss = { showImportPlaylistDialog = false }, + onDismiss = { showImportPlaylistDialog = false } ) if (showErrorPlaylistAddDialog) { @@ -376,20 +372,18 @@ fun YouTubePlaylistMenu( AsyncImage( model = song.thumbnailUrl, contentDescription = null, - modifier = - Modifier - .fillMaxSize() - .clip(RoundedCornerShape(ThumbnailCornerRadius)), + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) } }, supportingContent = { Text( - text = - joinByBullet( - song.artists.joinToString { it.name }, - makeTimeString(song.duration * 1000L), - ), + text = joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.duration * 1000L), + ) ) }, ) @@ -401,193 +395,183 @@ fun YouTubePlaylistMenu( val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( - contentPadding = - PaddingValues( - start = 0.dp, - top = 0.dp, - end = 0.dp, - bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), - ), + contentPadding = PaddingValues( + start = 0.dp, + top = 0.dp, + end = 0.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), ) { item { NewActionGrid( - actions = - buildList { - if (!isGuest) { - playlist.playEndpoint?.let { playEndpoint -> - add( - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.play), - onClick = { - playerConnection.playQueue(YouTubeQueue(playEndpoint)) - onDismiss() - }, - ), + actions = buildList { + if (!isGuest) { + playlist.playEndpoint?.let { playEndpoint -> + add( + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.play), + onClick = { + playerConnection.playQueue(YouTubeQueue(playEndpoint)) + onDismiss() + } ) - } - playlist.shuffleEndpoint?.let { shuffleEndpoint -> - add( - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.shuffle), - onClick = { - playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) - onDismiss() - }, - ), + ) + } + playlist.shuffleEndpoint?.let { shuffleEndpoint -> + add( + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.shuffle), + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + onDismiss() + } ) - } - playlist.radioEndpoint?.let { radioEndpoint -> - add( - NewAction( - icon = { - Icon( - painter = painterResource(R.drawable.radio), - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - text = stringResource(R.string.start_radio), - onClick = { - playerConnection.playQueue(YouTubeQueue(radioEndpoint)) - onDismiss() - }, - ), + ) + } + playlist.radioEndpoint?.let { radioEndpoint -> + add( + NewAction( + icon = { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + text = stringResource(R.string.start_radio), + onClick = { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + onDismiss() + } ) - } + ) } - }, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), + } + }, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) ) } item { Material3MenuGroup( - items = - listOfNotNull( - if (!isGuest) { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.play_next)) }, - description = { Text(text = stringResource(R.string.play_next_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.playlist_play), - contentDescription = null, - ) - }, - onClick = { - coroutineScope.launch { - songs - .ifEmpty { - withContext(Dispatchers.IO) { - YouTube - .playlist(playlist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - } - }.let { songs -> - playerConnection.playNext( - songs.map { - it - .copy(thumbnail = it.thumbnail.resize(544, 544)) - .toMediaItem() - }, - ) - } - } - onDismiss() - }, - ) - } else { - null - }, - if (!isGuest) { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.add_to_queue)) }, - description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.queue_music), - contentDescription = null, - ) - }, - onClick = { - coroutineScope.launch { - songs - .ifEmpty { - withContext(Dispatchers.IO) { - YouTube - .playlist(playlist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - } - }.let { songs -> - playerConnection.addToQueue(songs.map { it.toMediaItem() }) - } - } - onDismiss() - }, - ) - } else { - null - }, + items = listOfNotNull( + if (!isGuest) { Material3MenuItemData( - title = { Text(text = stringResource(R.string.add_to_playlist)) }, - description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, + title = { Text(text = stringResource(R.string.play_next)) }, + description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( - painter = painterResource(R.drawable.playlist_add), + painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { - showChoosePlaylistDialog = true - }, - ), + coroutineScope.launch { + songs + .ifEmpty { + withContext(Dispatchers.IO) { + YouTube + .playlist(playlist.id) + .completed() + .getOrNull() + ?.songs + .orEmpty() + } + }.let { songs -> + playerConnection.playNext(songs.map { + it.copy(thumbnail = it.thumbnail.resize(544, 544)) + .toMediaItem() + }) + } + } + onDismiss() + } + ) + } else null, + if (!isGuest) { Material3MenuItemData( - title = { - Text( - text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial", - ) - }, + title = { Text(text = stringResource(R.string.add_to_queue)) }, + description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( - painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), + painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { - coroutineScope.launch(Dispatchers.IO) { - if (isPinned) { - database.speedDialDao.delete(playlist.id) - } else { - database.speedDialDao.insert(SpeedDialItem.fromYTItem(playlist)) - } + coroutineScope.launch { + songs + .ifEmpty { + withContext(Dispatchers.IO) { + YouTube + .playlist(playlist.id) + .completed() + .getOrNull() + ?.songs + .orEmpty() + } + }.let { songs -> + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } } onDismiss() - }, - ), + } + ) + } else null, + Material3MenuItemData( + title = { Text(text = stringResource(R.string.add_to_playlist)) }, + description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + ) + }, + onClick = { + showChoosePlaylistDialog = true + } ), + Material3MenuItemData( + title = { + Text( + text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial" + ) + }, + icon = { + Icon( + painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), + contentDescription = null, + ) + }, + onClick = { + coroutineScope.launch(Dispatchers.IO) { + if (isPinned) { + database.speedDialDao.delete(playlist.id) + } else { + database.speedDialDao.insert(SpeedDialItem.fromYTItem(playlist)) + } + } + onDismiss() + } + ) + ) ) } @@ -595,215 +579,123 @@ fun YouTubePlaylistMenu( item { Material3MenuGroup( - items = - buildList { - if (songs.isNotEmpty()) { - add( - when (downloadState) { - Download.STATE_COMPLETED -> { - Material3MenuItemData( - title = { - Text( - text = stringResource(R.string.remove_download), - ) - }, - icon = { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - ) - }, - onClick = { - showRemoveDownloadDialog = true - }, - ) - } - - Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.downloading)) }, - icon = { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - }, - onClick = { - showRemoveDownloadDialog = true - }, - ) - } + items = buildList { + if (songs.isNotEmpty()) { + add( + when (downloadState) { + Download.STATE_COMPLETED -> { + Material3MenuItemData( + title = { + Text( + text = stringResource(R.string.remove_download) + ) + }, + icon = { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + }, + onClick = { + showRemoveDownloadDialog = true + } + ) + } + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + Material3MenuItemData( + title = { Text(text = stringResource(R.string.downloading)) }, + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + onClick = { + showRemoveDownloadDialog = true + } + ) + } + else -> { + Material3MenuItemData( + title = { Text(text = stringResource(R.string.action_download)) }, + description = { Text(text = stringResource(R.string.download_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + ) + }, + onClick = { + if (songs.isEmpty()) return@Material3MenuItemData + // Show format picker - fetch formats from first song + showDownloadFormatDialog = true + isLoadingFormats = true + availableFormats = emptyList() - else -> { - Material3MenuItemData( - title = { Text(text = stringResource(R.string.action_download)) }, - description = { Text(text = stringResource(R.string.download_desc)) }, - icon = { - Icon( - painter = painterResource(R.drawable.download), - contentDescription = null, - ) - }, - onClick = { - songs.forEach { song -> - val downloadRequest = - DownloadRequest - .Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false, - ) + coroutineScope.launch(Dispatchers.IO) { + try { + val firstSong = songs.first() + val formats = com.metrolist.music.utils.YTPlayerUtils + .getAllAvailableAudioFormats(firstSong.id) + .getOrNull() ?: emptyList() + withContext(Dispatchers.Main) { + availableFormats = formats + isLoadingFormats = false + } + } catch (e: Exception) { + Timber.tag("YouTubePlaylistMenu").e(e, "Failed to fetch formats") + withContext(Dispatchers.Main) { + isLoadingFormats = false + showDownloadFormatDialog = false + } } - }, - ) - } - }, - ) - } - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.export_playlist)) }, - icon = { - Icon( - painter = painterResource(R.drawable.share), - contentDescription = null, + } + } ) - }, - onClick = { showExportDialog = true }, - ), + } + } + ) + } + add( + Material3MenuItemData( + title = { Text(text = stringResource(R.string.share)) }, + description = { Text(text = stringResource(R.string.share_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + ) + }, + onClick = { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, playlist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } ) + ) + if (canSelect) { add( Material3MenuItemData( - title = { Text(text = stringResource(R.string.share)) }, - description = { Text(text = stringResource(R.string.share_desc)) }, + title = { Text(text = stringResource(R.string.select)) }, icon = { Icon( - painter = painterResource(R.drawable.share), + painter = painterResource(R.drawable.select_all), contentDescription = null, ) }, onClick = { - val intent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, playlist.shareLink) - } - context.startActivity(Intent.createChooser(intent, null)) onDismiss() - }, - ), - ) - if (canSelect) { - add( - Material3MenuItemData( - title = { Text(text = stringResource(R.string.select)) }, - icon = { - Icon( - painter = painterResource(R.drawable.select_all), - contentDescription = null, - ) - }, - onClick = { - onDismiss() - selectAction() - }, - ), + selectAction() + } ) - } - }, + ) + } + } ) } } - - if (showExportDialog) { - ExportDialog( - onDismiss = { showExportDialog = false }, - onShare = { format -> - coroutineScope.launch { - val ytSongs = - if (songs.isEmpty()) { - withContext(Dispatchers.IO) { - YouTube - .playlist(playlist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - } - } else { - songs - } - - val result = - when (format) { - "csv" -> exportYouTubePlaylistAsCSV(context, playlist.title, ytSongs) - "m3u" -> exportYouTubePlaylistAsM3U(context, playlist.title, ytSongs) - else -> Result.failure(IllegalArgumentException("Unknown format")) - } - result - .onSuccess { file -> - val uri = getExportFileUri(context, file) - val mime = if (format == "csv") "text/csv" else "audio/x-mpegurl" - shareFile(context, uri, mime) - }.onFailure { - Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() - } - } - onDismiss() - }, - onSave = { format -> - coroutineScope.launch { - val ytSongs = - if (songs.isEmpty()) { - withContext(Dispatchers.IO) { - YouTube - .playlist(playlist.id) - .completed() - .getOrNull() - ?.songs - .orEmpty() - } - } else { - songs - } - - val export = - when (format) { - "csv" -> exportYouTubePlaylistAsCSV(context, playlist.title, ytSongs) - "m3u" -> exportYouTubePlaylistAsM3U(context, playlist.title, ytSongs) - else -> Result.failure(IllegalArgumentException("Unknown format")) - } - export - .onSuccess { file -> - val mime = if (format == "csv") "text/csv" else "audio/x-mpegurl" - val save = saveToPublicDocuments(context, file, mime) - save - .onSuccess { Toast.makeText(context, R.string.export_success, Toast.LENGTH_SHORT).show() } - .onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } - }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } - } - onDismiss() - }, - ) - } -} - -private fun shareFile( - context: android.content.Context, - uri: android.net.Uri, - mimeType: String, -) { - val shareIntent = - Intent(Intent.ACTION_SEND).apply { - type = mimeType - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.export_playlist))) } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt index 9ad3ff5b4d..5d8664e87c 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt @@ -137,9 +137,42 @@ fun YouTubeSongMenu( onDismiss = { showChoosePlaylistDialog = false } ) - var showSelectArtistDialog by rememberSaveable { - mutableStateOf(false) - } + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + // Download format picker state + var showDownloadFormatDialog by rememberSaveable { mutableStateOf(false) } + var availableFormats by remember { mutableStateOf>(emptyList()) } + var isLoadingFormats by remember { mutableStateOf(false) } + val downloadUtil = LocalDownloadUtil.current + + if (showDownloadFormatDialog) { + com.metrolist.music.ui.component.DownloadFormatDialog( + isLoading = isLoadingFormats, + formats = availableFormats, + onFormatSelected = { format -> + Timber.tag("YouTubeSongMenu").d("Format selected: ${format.displayName} (itag=${format.itag})") + showDownloadFormatDialog = false + // Set target itag and start download (song already inserted when formats were fetched) + downloadUtil.setTargetItag(song.id, format.itag) + val downloadRequest = DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + }, + onDismiss = { + showDownloadFormatDialog = false + } + ) + } if (showSelectArtistDialog) { ListDialog( @@ -568,10 +601,10 @@ fun YouTubeSongMenu( item { Material3MenuGroup( - items = listOf( + items = buildList { when (download?.state) { Download.STATE_COMPLETED -> { - Material3MenuItemData( + add(Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download) @@ -591,10 +624,43 @@ fun YouTubeSongMenu( false, ) } - ) + )) + // Swap download option (re-download with different format) + add(Material3MenuItemData( + title = { Text(text = stringResource(R.string.swap_download)) }, + description = { Text(text = stringResource(R.string.swap_download_desc)) }, + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + }, + onClick = { + Timber.tag("YouTubeSongMenu").d("Swap download clicked for: ${song.id}") + showDownloadFormatDialog = true + isLoadingFormats = true + coroutineScope.launch(Dispatchers.IO) { + // First remove existing download + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + val result = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(song.id) + result.onSuccess { formats -> + availableFormats = formats + isLoadingFormats = false + }.onFailure { + availableFormats = emptyList() + isLoadingFormats = false + } + } + } + )) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { - Material3MenuItemData( + add(Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( @@ -610,10 +676,10 @@ fun YouTubeSongMenu( false, ) } - ) + )) } else -> { - Material3MenuItemData( + add(Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { @@ -623,25 +689,30 @@ fun YouTubeSongMenu( ) }, onClick = { - database.transaction { - insert(song.toMediaMetadata()) + // Show format picker dialog + showDownloadFormatDialog = true + isLoadingFormats = true + availableFormats = emptyList() + + coroutineScope.launch(Dispatchers.IO) { + try { + // Insert song to database first (needed for metadata embedding) + database.transaction { + insert(song.toMediaMetadata()) + } + val formats = com.metrolist.music.utils.YTPlayerUtils.getAllAvailableAudioFormats(song.id).getOrNull() ?: emptyList() + availableFormats = formats + } catch (e: Exception) { + Timber.tag("YouTubeSongMenu").e(e, "Failed to fetch formats") + } finally { + isLoadingFormats = false + } } - val downloadRequest = DownloadRequest - .Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false, - ) } - ) + )) } } - ) + } ) } diff --git a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/StorageSettings.kt b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/StorageSettings.kt index b289f04e7c..7515e606b5 100644 --- a/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/StorageSettings.kt +++ b/app/src/main/kotlin/com/metrolist/music/ui/screens/settings/StorageSettings.kt @@ -5,6 +5,10 @@ package com.metrolist.music.ui.screens.settings +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -41,6 +45,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile import androidx.navigation.NavController import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi @@ -50,6 +55,8 @@ import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R +import com.metrolist.music.constants.CustomDownloadPathEnabledKey +import com.metrolist.music.constants.CustomDownloadPathUriKey import com.metrolist.music.constants.EnableSongCacheKey import com.metrolist.music.constants.MaxImageCacheSizeKey import com.metrolist.music.constants.MaxSongCacheSizeKey @@ -67,9 +74,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import okio.ByteString.Companion.encodeUtf8 -import java.io.File +import timber.log.Timber import kotlin.math.roundToInt +private const val TAG = "StorageSettings" + @OptIn(ExperimentalCoilApi::class, ExperimentalMaterial3Api::class, DelicateCoilApi::class) @Composable fun StorageSettings( @@ -97,6 +106,58 @@ fun StorageSettings( defaultValue = true ) + // Custom download path preferences + val (customDownloadPathEnabled, onCustomDownloadPathEnabledChange) = rememberPreference( + key = CustomDownloadPathEnabledKey, + defaultValue = false + ) + val (customDownloadPathUri, onCustomDownloadPathUriChange) = rememberPreference( + key = CustomDownloadPathUriKey, + defaultValue = "" + ) + + // Check if custom path permission is still valid + var customPathValid by remember { mutableStateOf(true) } + LaunchedEffect(customDownloadPathUri) { + if (customDownloadPathUri.isNotEmpty()) { + Timber.tag(TAG).d("Checking custom path validity: $customDownloadPathUri") + try { + val uri = Uri.parse(customDownloadPathUri) + val docFile = DocumentFile.fromTreeUri(context, uri) + customPathValid = docFile?.canWrite() == true + Timber.tag(TAG).d("Custom path valid: $customPathValid, canWrite: ${docFile?.canWrite()}") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error checking custom path validity") + customPathValid = false + } + } + } + + // Folder picker launcher + val folderPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + Timber.tag(TAG).d("Folder picker result: $uri") + uri?.let { selectedUri -> + Timber.tag(TAG).d("Taking persistable permission for: $selectedUri") + // Take persistable permission + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { + context.contentResolver.takePersistableUriPermission(selectedUri, takeFlags) + Timber.tag(TAG).d("Persistable permission granted successfully") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to take persistable permission") + } + + onCustomDownloadPathUriChange(selectedUri.toString()) + customPathValid = true + Timber.tag(TAG).d("Custom download path set to: $selectedUri") + } ?: run { + Timber.tag(TAG).d("Folder picker cancelled by user") + } + } + var clearDownloads by remember { mutableStateOf(false) } var clearCacheDialog by remember { mutableStateOf(false) } var clearImageCacheDialog by remember { mutableStateOf(false) } @@ -317,6 +378,96 @@ fun StorageSettings( ), ) + // Custom Download Path Settings + Material3SettingsGroup( + title = stringResource(R.string.custom_download_path), + items = buildList { + add( + Material3SettingsItem( + icon = painterResource(R.drawable.storage), + title = { Text(stringResource(R.string.custom_download_path)) }, + description = { Text(stringResource(R.string.custom_download_path_desc)) }, + trailingContent = { + androidx.compose.material3.Switch( + checked = customDownloadPathEnabled, + onCheckedChange = { enabled -> + Timber.tag(TAG).d("Custom download path toggle: $enabled") + if (enabled && customDownloadPathUri.isEmpty()) { + // Launch folder picker if enabling without a path set + Timber.tag(TAG).d("No path set, launching folder picker") + folderPickerLauncher.launch(null) + } + onCustomDownloadPathEnabledChange(enabled) + } + ) + } + ) + ) + if (customDownloadPathEnabled) { + add( + Material3SettingsItem( + icon = painterResource(R.drawable.download), + title = { Text(stringResource(R.string.select_download_folder)) }, + description = { + if (customDownloadPathUri.isNotEmpty()) { + val displayPath = try { + val uri = Uri.parse(customDownloadPathUri) + // Extract full path from URI (e.g., "primary:Music/Metrolist" -> "Music/Metrolist") + val docId = android.provider.DocumentsContract.getTreeDocumentId(uri) + docId?.substringAfter(":")?.ifEmpty { docId } ?: DocumentFile.fromTreeUri(context, uri)?.name ?: customDownloadPathUri + } catch (e: Exception) { + customDownloadPathUri + } + Column { + Text( + text = stringResource(R.string.current_path, displayPath), + style = MaterialTheme.typography.bodySmall + ) + if (!customPathValid) { + Text( + text = stringResource(R.string.permission_lost_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } else { + Text(stringResource(R.string.no_folder_selected)) + } + }, + onClick = { + folderPickerLauncher.launch(null) + } + ) + ) + if (customDownloadPathUri.isNotEmpty()) { + add( + Material3SettingsItem( + icon = painterResource(R.drawable.close), + title = { Text(stringResource(R.string.reset_download_path)) }, + onClick = { + Timber.tag(TAG).d("Resetting custom download path") + // Release persistable permission + try { + val uri = Uri.parse(customDownloadPathUri) + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.releasePersistableUriPermission(uri, takeFlags) + Timber.tag(TAG).d("Released persistable permission for: $uri") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Error releasing persistable permission (may already be released)") + } + onCustomDownloadPathUriChange("") + onCustomDownloadPathEnabledChange(false) + Timber.tag(TAG).d("Custom download path reset complete") + } + ) + ) + } + } + } + ) + Material3SettingsGroup( title = stringResource(R.string.song_cache), items = listOf( diff --git a/app/src/main/kotlin/com/metrolist/music/utils/CoverArtEmbedder.kt b/app/src/main/kotlin/com/metrolist/music/utils/CoverArtEmbedder.kt new file mode 100644 index 0000000000..6bf5f2d6c0 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/utils/CoverArtEmbedder.kt @@ -0,0 +1,216 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.utils + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** + * High-level utility for embedding metadata into M4A files. + * Uses Bento4 native library via CoverArtNative JNI wrapper. + */ +@Singleton +class CoverArtEmbedder @Inject constructor( + @ApplicationContext private val context: Context, +) { + companion object { + private const val TAG = "CoverArtEmbedder" + } + + /** + * Embed metadata into an M4A file at a SAF URI location. + * Creates a temporary copy, embeds metadata, then replaces the original. + * + * @param fileUri SAF URI of the M4A file + * @param artworkData Cover art image data (JPEG or PNG), can be null + * @param title Song title, can be null + * @param artist Artist name, can be null + * @param album Album name, can be null + * @param year Year string, can be null + * @param albumArtist Album artist name, can be null + * @param trackNumber Track number (0 to skip) + * @param totalTracks Total tracks in album (0 if unknown) + * @return true if successful, false otherwise + */ + suspend fun embedMetadataIntoFile( + fileUri: Uri, + artworkData: ByteArray?, + title: String?, + artist: String?, + album: String?, + year: String?, + albumArtist: String? = null, + trackNumber: Int = 0, + totalTracks: Int = 0 + ): Boolean = withContext(Dispatchers.IO) { + Timber.tag(TAG).d("=== Starting metadata embedding ===") + Timber.tag(TAG).d("File URI: $fileUri") + Timber.tag(TAG).d("Title: $title, Artist: $artist, Album: $album, Year: $year") + Timber.tag(TAG).d("Album Artist: $albumArtist, Track: $trackNumber/$totalTracks") + Timber.tag(TAG).d("Artwork size: ${artworkData?.size ?: 0} bytes") + + val tempDir = File(context.cacheDir, "coverart_temp") + if (!tempDir.exists()) { + tempDir.mkdirs() + } + + val inputFile = File(tempDir, "input_${System.currentTimeMillis()}.m4a") + val outputFile = File(tempDir, "output_${System.currentTimeMillis()}.m4a") + + try { + // Step 1: Copy SAF file to local temp file + Timber.tag(TAG).d("Step 1: Copying SAF file to temp...") + val docFile = DocumentFile.fromSingleUri(context, fileUri) + if (docFile == null || !docFile.exists()) { + Timber.tag(TAG).e("File does not exist: $fileUri") + return@withContext false + } + + context.contentResolver.openInputStream(fileUri)?.use { input -> + FileOutputStream(inputFile).use { output -> + val bytes = input.copyTo(output) + Timber.tag(TAG).d("Copied $bytes bytes to temp file") + } + } ?: run { + Timber.tag(TAG).e("Failed to open input stream for: $fileUri") + return@withContext false + } + + // Step 2: Embed metadata using native library + Timber.tag(TAG).d("Step 2: Embedding metadata via native library...") + val success = CoverArtNative.embedMetadata( + inputPath = inputFile.absolutePath, + outputPath = outputFile.absolutePath, + artworkData = artworkData, + title = title, + artist = artist, + album = album, + year = year, + albumArtist = albumArtist, + trackNumber = trackNumber, + totalTracks = totalTracks + ) + + if (!success) { + Timber.tag(TAG).e("Native embedMetadata failed") + return@withContext false + } + + Timber.tag(TAG).d("Native embedding successful, output size: ${outputFile.length()} bytes") + + // Step 3: Copy result back to SAF location + Timber.tag(TAG).d("Step 3: Copying result back to SAF location...") + context.contentResolver.openOutputStream(fileUri, "wt")?.use { output -> + outputFile.inputStream().use { input -> + val bytes = input.copyTo(output) + Timber.tag(TAG).d("Wrote $bytes bytes back to SAF file") + } + } ?: run { + Timber.tag(TAG).e("Failed to open output stream for: $fileUri") + return@withContext false + } + + Timber.tag(TAG).d("=== Metadata embedding completed successfully ===") + true + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error embedding metadata") + false + } finally { + // Cleanup temp files + if (inputFile.exists()) { + inputFile.delete() + Timber.tag(TAG).d("Deleted temp input file") + } + if (outputFile.exists()) { + outputFile.delete() + Timber.tag(TAG).d("Deleted temp output file") + } + } + } + + /** + * Embed metadata into a local file (non-SAF). + * Creates output file, then replaces original. + * + * @param filePath Path to the M4A file + * @param artworkData Cover art image data (JPEG or PNG), can be null + * @param title Song title, can be null + * @param artist Artist name, can be null + * @param album Album name, can be null + * @param year Year string, can be null + * @param albumArtist Album artist name, can be null + * @param trackNumber Track number (0 to skip) + * @param totalTracks Total tracks in album (0 if unknown) + * @return true if successful, false otherwise + */ + suspend fun embedMetadataIntoLocalFile( + filePath: String, + artworkData: ByteArray?, + title: String?, + artist: String?, + album: String?, + year: String?, + albumArtist: String? = null, + trackNumber: Int = 0, + totalTracks: Int = 0 + ): Boolean = withContext(Dispatchers.IO) { + Timber.tag(TAG).d("=== Starting local file metadata embedding ===") + Timber.tag(TAG).d("File path: $filePath") + + val inputFile = File(filePath) + if (!inputFile.exists()) { + Timber.tag(TAG).e("Input file does not exist: $filePath") + return@withContext false + } + + val outputFile = File(inputFile.parent, "temp_${inputFile.name}") + + try { + Timber.tag(TAG).d("Embedding metadata via native library...") + val success = CoverArtNative.embedMetadata( + inputPath = inputFile.absolutePath, + outputPath = outputFile.absolutePath, + artworkData = artworkData, + title = title, + artist = artist, + album = album, + year = year, + albumArtist = albumArtist, + trackNumber = trackNumber, + totalTracks = totalTracks + ) + + if (!success) { + Timber.tag(TAG).e("Native embedMetadata failed") + return@withContext false + } + + Timber.tag(TAG).d("Native embedding successful, replacing original...") + + // Replace original with output + if (inputFile.delete() && outputFile.renameTo(inputFile)) { + Timber.tag(TAG).d("=== Local file metadata embedding completed ===") + true + } else { + Timber.tag(TAG).e("Failed to replace original file") + false + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error embedding metadata into local file") + outputFile.delete() + false + } + } +} diff --git a/app/src/main/kotlin/com/metrolist/music/utils/CoverArtNative.kt b/app/src/main/kotlin/com/metrolist/music/utils/CoverArtNative.kt new file mode 100644 index 0000000000..8b483f85b3 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/utils/CoverArtNative.kt @@ -0,0 +1,59 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.utils + +/** + * JNI wrapper for native Bento4-based metadata embedding. + * This class provides low-level access to native functions for embedding + * cover art and text metadata into M4A/MP4 files. + */ +object CoverArtNative { + init { + System.loadLibrary("coverart") + } + + /** + * Embed metadata (cover art, title, artist, album, year, album artist, track number) into an M4A/MP4 file. + * All text is stored as UTF-8 (supports Hebrew, Arabic, and all Unicode). + * + * @param inputPath Path to the input M4A/MP4 file + * @param outputPath Path for the output file with embedded metadata + * @param artworkData JPEG or PNG image data for cover art (can be null) + * @param title Song title (can be null) + * @param artist Artist name (can be null) + * @param album Album name (can be null) + * @param year Year string (can be null) + * @param albumArtist Album artist name (can be null) + * @param trackNumber Track number (0 or negative to skip) + * @param totalTracks Total tracks in album (0 if unknown) + * @return true if successful, false otherwise + */ + external fun embedMetadata( + inputPath: String, + outputPath: String, + artworkData: ByteArray?, + title: String?, + artist: String?, + album: String?, + year: String?, + albumArtist: String?, + trackNumber: Int, + totalTracks: Int + ): Boolean + + /** + * Defragment a DASH/fragmented MP4 file to standard MP4. + * This is needed because DASH files use moof/mdat structure instead of moov/mdat. + * + * @param inputPath Path to the fragmented input file + * @param outputPath Path for the defragmented output file + * @return true if successful, false otherwise + */ + external fun defragmentFile( + inputPath: String, + outputPath: String + ): Boolean +} diff --git a/app/src/main/kotlin/com/metrolist/music/utils/DataStore.kt b/app/src/main/kotlin/com/metrolist/music/utils/DataStore.kt index 699fc1a8ce..344426d73b 100644 --- a/app/src/main/kotlin/com/metrolist/music/utils/DataStore.kt +++ b/app/src/main/kotlin/com/metrolist/music/utils/DataStore.kt @@ -52,6 +52,18 @@ inline fun > enumPreference( defaultValue: T, ) = ReadOnlyProperty { _, _ -> context.dataStore[key].toEnum(defaultValue) } +fun booleanPreference( + context: Context, + key: Preferences.Key, + defaultValue: Boolean, +) = ReadOnlyProperty { _, _ -> context.dataStore[key] ?: defaultValue } + +fun stringPreference( + context: Context, + key: Preferences.Key, + defaultValue: String, +) = ReadOnlyProperty { _, _ -> context.dataStore[key] ?: defaultValue } + @Composable fun rememberPreference( key: Preferences.Key, diff --git a/app/src/main/kotlin/com/metrolist/music/utils/DownloadExportHelper.kt b/app/src/main/kotlin/com/metrolist/music/utils/DownloadExportHelper.kt new file mode 100644 index 0000000000..252081e338 --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/utils/DownloadExportHelper.kt @@ -0,0 +1,467 @@ +/** + * Metrolist Project (C) 2026 + * Licensed under GPL-3.0 | See git history for contributors + */ + +package com.metrolist.music.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import androidx.media3.datasource.cache.SimpleCache +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.toBitmap +import com.metrolist.music.db.MusicDatabase +import com.metrolist.music.db.entities.FormatEntity +import com.metrolist.music.di.DownloadCache +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DownloadExportHelper @Inject constructor( + @ApplicationContext private val context: Context, + private val database: MusicDatabase, + @DownloadCache private val downloadCache: SimpleCache, + private val coverArtEmbedder: CoverArtEmbedder, +) { + companion object { + private const val TAG = "DownloadExportHelper" + } + + /** + * Export a downloaded song from the internal cache to a custom SAF directory. + * Returns the URI of the exported file, or null if export failed. + */ + suspend fun exportToCustomPath( + songId: String, + customPathUri: String + ): String? = withContext(Dispatchers.IO) { + Timber.tag(TAG).d("=== Starting export for song: $songId ===") + Timber.tag(TAG).d("Custom path URI: $customPathUri") + + try { + Timber.tag(TAG).d("Fetching song from database...") + val song = database.song(songId).first() ?: run { + Timber.tag(TAG).w("Song not found in database: $songId") + return@withContext null + } + Timber.tag(TAG).d("Song found: ${song.song.title}") + + Timber.tag(TAG).d("Fetching format info...") + val format = database.format(songId).first() + val extension = getExtensionFromFormat(format) + Timber.tag(TAG).d("Format: ${format?.mimeType ?: "unknown"}, Extension: $extension") + + // Build folder structure: downloadFolder/Artist/Album/Title.ext + val firstArtist = song.artists.firstOrNull()?.name ?: "Unknown Artist" + val allArtists = song.artists.joinToString(", ") { it.name } + .ifEmpty { "Unknown Artist" } + val albumName = song.album?.title ?: song.song.albumName ?: "Singles" + val title = song.song.title + val sanitizedArtistFolder = sanitizeFilename(firstArtist) + val sanitizedAlbumFolder = sanitizeFilename(albumName) + val sanitizedFilename = sanitizeFilename("$title.$extension") + Timber.tag(TAG).d("Artist folder: $sanitizedArtistFolder, Album folder: $sanitizedAlbumFolder, Filename: $sanitizedFilename") + + Timber.tag(TAG).d("Parsing parent URI...") + val parentUri = Uri.parse(customPathUri) + val rootDoc = DocumentFile.fromTreeUri(context, parentUri) ?: run { + Timber.tag(TAG).e("Cannot access custom path: $customPathUri") + return@withContext null + } + Timber.tag(TAG).d("Root document: ${rootDoc.name}, canRead: ${rootDoc.canRead()}, canWrite: ${rootDoc.canWrite()}") + + if (!rootDoc.canWrite()) { + Timber.tag(TAG).e("Cannot write to custom path: $customPathUri") + return@withContext null + } + + // Create or get artist subfolder + var artistFolder = rootDoc.findFile(sanitizedArtistFolder) + if (artistFolder == null || !artistFolder.isDirectory) { + Timber.tag(TAG).d("Creating artist folder: $sanitizedArtistFolder") + artistFolder = rootDoc.createDirectory(sanitizedArtistFolder) + if (artistFolder == null) { + Timber.tag(TAG).e("Failed to create artist folder: $sanitizedArtistFolder") + return@withContext null + } + } + Timber.tag(TAG).d("Artist folder ready: ${artistFolder.uri}") + + // Create or get album subfolder inside artist folder + var albumFolder = artistFolder.findFile(sanitizedAlbumFolder) + if (albumFolder == null || !albumFolder.isDirectory) { + Timber.tag(TAG).d("Creating album folder: $sanitizedAlbumFolder") + albumFolder = artistFolder.createDirectory(sanitizedAlbumFolder) + if (albumFolder == null) { + Timber.tag(TAG).e("Failed to create album folder: $sanitizedAlbumFolder") + return@withContext null + } + } + Timber.tag(TAG).d("Album folder ready: ${albumFolder.uri}") + + // Check if file already exists in album folder and delete it + val existingFile = albumFolder.findFile(sanitizedFilename) + if (existingFile != null) { + Timber.tag(TAG).d("Existing file found, deleting...") + existingFile.delete() + } + + // Create the new file in album folder + val mimeType = format?.mimeType ?: "audio/mp4" + Timber.tag(TAG).d("Creating new file with mimeType: $mimeType") + val newFile = albumFolder.createFile(mimeType, sanitizedFilename) ?: run { + Timber.tag(TAG).e("Failed to create file: $sanitizedFilename") + return@withContext null + } + Timber.tag(TAG).d("Created file: ${newFile.uri}") + + // Copy data from cache to new file + Timber.tag(TAG).d("Opening output stream...") + val outputStream = context.contentResolver.openOutputStream(newFile.uri) ?: run { + Timber.tag(TAG).e("Failed to open output stream for: ${newFile.uri}") + newFile.delete() + return@withContext null + } + + var totalBytesWritten = 0L + outputStream.use { out -> + Timber.tag(TAG).d("Getting cached spans for: $songId") + val cachedSpans = downloadCache.getCachedSpans(songId) + Timber.tag(TAG).d("Found ${cachedSpans.size} cached spans") + + if (cachedSpans.isEmpty()) { + Timber.tag(TAG).w("No cached data found for: $songId") + newFile.delete() + return@withContext null + } + + // Sort spans by position and write them in order + val sortedSpans = cachedSpans.sortedBy { it.position } + Timber.tag(TAG).d("Spans sorted by position, starting copy...") + + for ((index, span) in sortedSpans.withIndex()) { + Timber.tag(TAG).d("Processing span $index: position=${span.position}, length=${span.length}, file=${span.file?.name}") + span.file?.inputStream()?.use { input -> + val bytes = input.copyTo(out) + totalBytesWritten += bytes + Timber.tag(TAG).d("Copied $bytes bytes from span $index") + } + } + } + + Timber.tag(TAG).d("Total bytes written: $totalBytesWritten") + + val exportedUri = newFile.uri.toString() + + // Embed metadata for M4A files (128kbps+ only - lower bitrates have compatibility issues) + val bitrateKbps = (format?.bitrate ?: 0) / 1000 + if (format == null) { + Timber.tag(TAG).w("Format entity is null for $songId - metadata embedding skipped") + } + Timber.tag(TAG).d("Metadata check: extension=$extension, bitrate=${bitrateKbps}kbps, eligible=${extension == "m4a" && bitrateKbps >= 128}") + if (extension == "m4a" && bitrateKbps >= 128) { + Timber.tag(TAG).d("M4A file detected (${bitrateKbps}kbps), embedding metadata...") + try { + val artworkData = fetchArtworkData(song.song.thumbnailUrl) + + // Try album relationship first, fall back to song entity fields + var albumName = song.album?.title ?: song.song.albumName + var year = (song.album?.year ?: song.song.year)?.toString() + var albumArtist: String? = null + var trackNumber = 0 + var totalTracks = 0 + var fetchedAlbumId: String? = song.song.albumId + + // If no album info at all, fetch from YouTube using next() + if (albumName == null && fetchedAlbumId == null) { + Timber.tag(TAG).d("No album info, fetching song metadata from YouTube...") + try { + val nextResult = com.metrolist.innertube.YouTube.next( + com.metrolist.innertube.models.WatchEndpoint(videoId = songId) + ).getOrNull() + val currentSong = nextResult?.items?.getOrNull(nextResult.currentIndex ?: 0) + val songAlbum = currentSong?.album + if (songAlbum != null) { + albumName = songAlbum.name + fetchedAlbumId = songAlbum.id + Timber.tag(TAG).d("Fetched album from next(): $albumName (id=$fetchedAlbumId)") + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to fetch song metadata from YouTube") + } + } + + // If we have albumId (original or fetched), get full album details + if ((albumName == null || year == null) && fetchedAlbumId != null) { + Timber.tag(TAG).d("Fetching album details for: $fetchedAlbumId") + try { + val albumPage = com.metrolist.innertube.YouTube.album(fetchedAlbumId).getOrNull() + if (albumPage != null) { + if (albumName == null) albumName = albumPage.album.title + if (year == null) year = albumPage.album.year?.toString() + albumArtist = albumPage.album.artists?.firstOrNull()?.name + totalTracks = albumPage.songs.size + val trackIndex = albumPage.songs.indexOfFirst { it.id == songId } + if (trackIndex >= 0) { + trackNumber = trackIndex + 1 + } + Timber.tag(TAG).d("Fetched album details - album: $albumName, year: $year, albumArtist: $albumArtist, track: $trackNumber/$totalTracks") + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to fetch album info from YouTube") + } + } + + val embedSuccess = coverArtEmbedder.embedMetadataIntoFile( + fileUri = newFile.uri, + artworkData = artworkData, + title = song.song.title, + artist = allArtists, + album = albumName, + year = year, + albumArtist = albumArtist, + trackNumber = trackNumber, + totalTracks = totalTracks + ) + + if (embedSuccess) { + Timber.tag(TAG).d("Metadata embedded successfully") + } else { + Timber.tag(TAG).w("Metadata embedding failed, file still exported without metadata") + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error embedding metadata, continuing without metadata") + } + } else if (extension == "m4a") { + Timber.tag(TAG).d("M4A file (${bitrateKbps}kbps) - skipping metadata embed (low bitrate not supported)") + } + + // Update database with the new URI + Timber.tag(TAG).d("Updating database with downloadUri...") + database.updateDownloadUri(songId, exportedUri) + + Timber.tag(TAG).d("=== Successfully exported $songId to $exportedUri ===") + exportedUri + } catch (e: IOException) { + Timber.tag(TAG).e(e, "IO error exporting song: $songId") + null + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error exporting song: $songId") + null + } + } + + /** + * Delete a song from the custom path and clear the downloadUri in the database. + * Also cleans up empty parent folders (Album and Artist folders). + */ + suspend fun deleteFromCustomPath(songId: String, customPathUri: String? = null): Boolean = withContext(Dispatchers.IO) { + Timber.tag(TAG).d("=== Starting delete for song: $songId ===") + + try { + Timber.tag(TAG).d("Fetching downloadUri from database...") + val downloadUri = database.getDownloadUri(songId) + Timber.tag(TAG).d("downloadUri: $downloadUri") + + if (downloadUri.isNullOrEmpty()) { + Timber.tag(TAG).d("No external file URI stored, nothing to delete") + return@withContext true // Nothing to delete + } + + Timber.tag(TAG).d("Parsing URI and getting DocumentFile...") + val uri = Uri.parse(downloadUri) + val docFile = DocumentFile.fromSingleUri(context, uri) + Timber.tag(TAG).d("DocumentFile exists: ${docFile?.exists()}, name: ${docFile?.name}") + + val deleted = docFile?.delete() ?: false + Timber.tag(TAG).d("Delete operation result: $deleted") + + if (deleted || docFile?.exists() == false) { + Timber.tag(TAG).d("Clearing downloadUri in database...") + database.updateDownloadUri(songId, null) + + // Clean up empty parent folders if we have access to the root + if (customPathUri != null) { + cleanupEmptyParentFolders(downloadUri, customPathUri) + } + + Timber.tag(TAG).d("=== Successfully deleted external file for: $songId ===") + return@withContext true + } + + Timber.tag(TAG).w("Failed to delete external file for: $songId") + false + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error deleting external file for: $songId") + false + } + } + + /** + * Clean up empty Album and Artist folders after file deletion. + */ + private fun cleanupEmptyParentFolders(deletedFileUri: String, rootUri: String) { + try { + Timber.tag(TAG).d("Cleaning up empty parent folders...") + + val rootDoc = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return + + // Extract the path segments from the deleted file URI + // URI format: content://authority/tree/treeId/document/treeId%3Apath%2Fto%2Ffile.m4a + val fileUri = Uri.parse(deletedFileUri) + val docId = DocumentsContract.getDocumentId(fileUri) + + // docId format: "primary:Music/Artist/Album/song.m4a" or similar + val pathPart = docId.substringAfter(":", "") + if (pathPart.isEmpty()) return + + val segments = pathPart.split("/") + if (segments.size < 3) return // Need at least root/artist/album/file + + // Get the root path (the custom download folder path within the tree) + val rootDocId = DocumentsContract.getTreeDocumentId(Uri.parse(rootUri)) + val rootPath = rootDocId.substringAfter(":", "") + + // Calculate relative path from root + val relativePath = if (rootPath.isNotEmpty() && pathPart.startsWith(rootPath)) { + pathPart.removePrefix(rootPath).removePrefix("/") + } else { + pathPart + } + + val relativeSegments = relativePath.split("/").filter { it.isNotEmpty() } + if (relativeSegments.size < 3) return // Need artist/album/file + + val artistName = relativeSegments[0] + val albumName = relativeSegments[1] + + Timber.tag(TAG).d("Checking folders - Artist: $artistName, Album: $albumName") + + // Find and check album folder + val artistFolder = rootDoc.findFile(artistName) + if (artistFolder != null && artistFolder.isDirectory) { + val albumFolder = artistFolder.findFile(albumName) + if (albumFolder != null && albumFolder.isDirectory) { + val albumFiles = albumFolder.listFiles() + if (albumFiles.isEmpty()) { + Timber.tag(TAG).d("Album folder is empty, deleting: $albumName") + albumFolder.delete() + } + } + + // Check if artist folder is now empty + val artistFiles = artistFolder.listFiles() + if (artistFiles.isEmpty()) { + Timber.tag(TAG).d("Artist folder is empty, deleting: $artistName") + artistFolder.delete() + } + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Error cleaning up empty folders (non-critical)") + } + } + + /** + * Check if the given URI is still accessible with persisted permissions. + */ + fun verifyPathAccess(uri: String): Boolean { + Timber.tag(TAG).d("Verifying path access for: $uri") + return try { + val parsedUri = Uri.parse(uri) + val docFile = DocumentFile.fromTreeUri(context, parsedUri) + val canRead = docFile?.canRead() == true + val canWrite = docFile?.canWrite() == true + Timber.tag(TAG).d("Path access result - canRead: $canRead, canWrite: $canWrite") + canRead && canWrite + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error verifying path access: $uri") + false + } + } + + /** + * Check if a specific downloaded file still exists and is accessible. + */ + fun verifyFileAccess(uri: String): Boolean { + Timber.tag(TAG).d("Verifying file access for: $uri") + return try { + val parsedUri = Uri.parse(uri) + val docFile = DocumentFile.fromSingleUri(context, parsedUri) + val exists = docFile?.exists() == true + val canRead = docFile?.canRead() == true + Timber.tag(TAG).d("File access result - exists: $exists, canRead: $canRead") + exists && canRead + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error verifying file access: $uri") + false + } + } + + private fun getExtensionFromFormat(format: FormatEntity?): String { + return when { + format == null -> "m4a" + format.mimeType.contains("audio/webm") -> "ogg" + format.mimeType.contains("audio/mp4") -> "m4a" + format.mimeType.contains("audio/mpeg") -> "mp3" + format.mimeType.contains("audio/ogg") -> "ogg" + else -> "m4a" + } + } + + private fun sanitizeFilename(filename: String): String { + // Remove or replace characters that are invalid in filenames + return filename + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .replace(Regex("\\s+"), " ") + .trim() + .take(200) // Limit filename length + } + + /** + * Fetch artwork from URL and convert to JPEG byte array for embedding. + */ + private suspend fun fetchArtworkData(thumbnailUrl: String?): ByteArray? { + if (thumbnailUrl.isNullOrEmpty()) { + Timber.tag(TAG).d("No thumbnail URL provided") + return null + } + + Timber.tag(TAG).d("Fetching artwork from: $thumbnailUrl") + + return try { + val imageLoader = ImageLoader.Builder(context).build() + val request = ImageRequest.Builder(context) + .data(thumbnailUrl) + .build() + + val result = imageLoader.execute(request) + if (result is SuccessResult) { + val bitmap = result.image.toBitmap() + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + val data = outputStream.toByteArray() + Timber.tag(TAG).d("Artwork fetched successfully: ${data.size} bytes") + data + } else { + Timber.tag(TAG).w("Failed to load artwork image") + null + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error fetching artwork") + null + } + } +} diff --git a/app/src/main/kotlin/com/metrolist/music/utils/YTPlayerUtils.kt b/app/src/main/kotlin/com/metrolist/music/utils/YTPlayerUtils.kt index 3564419704..e5f792fca3 100644 --- a/app/src/main/kotlin/com/metrolist/music/utils/YTPlayerUtils.kt +++ b/app/src/main/kotlin/com/metrolist/music/utils/YTPlayerUtils.kt @@ -25,6 +25,7 @@ import com.metrolist.innertube.models.YouTubeClient.Companion.WEB import com.metrolist.innertube.models.YouTubeClient.Companion.WEB_CREATOR import com.metrolist.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.metrolist.innertube.models.response.PlayerResponse +import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.constants.AudioQuality import com.metrolist.music.utils.cipher.CipherDeobfuscator import com.metrolist.music.utils.YTPlayerUtils.MAIN_CLIENT @@ -79,6 +80,7 @@ object YTPlayerUtils { playlistId: String? = null, audioQuality: AudioQuality, connectivityManager: ConnectivityManager, + targetItag: Int = 0, // 0 = auto select, >0 = use specific itag ): Result = runCatching { Timber.tag(TAG).d("=== PLAYER RESPONSE FOR PLAYBACK ===") Timber.tag(TAG).d("videoId: $videoId") @@ -145,8 +147,6 @@ object YTPlayerUtils { } } - // If we still don't have a valid response, throw - val audioConfig = mainPlayerResponse.playerConfig?.audioConfig val videoDetails = mainPlayerResponse.videoDetails val playbackTracking = mainPlayerResponse.playbackTracking @@ -230,6 +230,7 @@ object YTPlayerUtils { responseToUse, audioQuality, connectivityManager, + targetItag, ) if (format == null) { @@ -419,7 +420,52 @@ object YTPlayerUtils { playerResponse: PlayerResponse, audioQuality: AudioQuality, connectivityManager: ConnectivityManager, + targetItag: Int = 0, // 0 = auto, >0 = exact itag ): PlayerResponse.StreamingData.Format? { + // If exact itag requested, find it directly (for user-selected quality downloads) + if (targetItag > 0) { + val exactFormat = playerResponse.streamingData?.adaptiveFormats + ?.find { it.itag == targetItag } + if (exactFormat != null) { + Timber.tag(logTag).d("Using exact itag $targetItag: ${exactFormat.mimeType}, bitrate: ${exactFormat.bitrate}") + return exactFormat + } + Timber.tag(logTag).w("Requested itag $targetItag not found, trying to find same codec type") + + // Try to find a format of the same codec type (M4A or OPUS) with similar bitrate + // This handles cases where different songs have different available itags + val allFormats = playerResponse.streamingData?.adaptiveFormats?.filter { it.isAudio && it.isOriginal } + val requestedFormat = allFormats?.find { it.itag == targetItag } + + // Determine if the requested format was M4A or OPUS based on common itags + // M4A itags: 139, 140, 141, 256, 258, 327, 380, 774 + // OPUS itags: 249, 250, 251, 338 + val isM4aRequested = targetItag in listOf(139, 140, 141, 256, 258, 327, 380, 774) + val isOpusRequested = targetItag in listOf(249, 250, 251, 338) + + if (isM4aRequested) { + // Find best M4A format available + val m4aFormat = allFormats + ?.filter { it.mimeType.contains("mp4a") || it.mimeType.contains("audio/mp4") } + ?.maxByOrNull { it.bitrate } + if (m4aFormat != null) { + Timber.tag(logTag).d("Using alternative M4A format: itag=${m4aFormat.itag}, bitrate=${m4aFormat.bitrate}") + return m4aFormat + } + } else if (isOpusRequested) { + // Find best OPUS format available + val opusFormat = allFormats + ?.filter { it.mimeType.contains("opus") || it.mimeType.contains("audio/webm") } + ?.maxByOrNull { it.bitrate } + if (opusFormat != null) { + Timber.tag(logTag).d("Using alternative OPUS format: itag=${opusFormat.itag}, bitrate=${opusFormat.bitrate}") + return opusFormat + } + } + + Timber.tag(logTag).w("No matching codec type found, falling back to auto selection") + } + Timber.tag(logTag).d("Finding format with audioQuality: $audioQuality, network metered: ${connectivityManager.isActiveNetworkMetered}") val format = playerResponse.streamingData?.adaptiveFormats @@ -565,4 +611,108 @@ object YTPlayerUtils { fun forceRefreshForVideo(videoId: String) { Timber.tag(logTag).d("Force refreshing for videoId: $videoId") } + + /** + * Data class representing an available audio format option for download. + */ + data class AudioFormatOption( + val itag: Int, + val bitrate: Int, + val bitrateKbps: Int, + val mimeType: String, + val codec: String, + ) { + val displayName: String + get() = "${bitrateKbps}kbps ${codec.uppercase()}" + + val isM4a: Boolean + get() = codec.equals("M4A", ignoreCase = true) || mimeType.contains("mp4a") + + // Only higher quality M4A (128kbps+) reliably supports metadata embedding + // Lower bitrate M4A may have compatibility issues with Bento4 + val supportsMetadata: Boolean + get() = isM4a && bitrateKbps >= 128 + } + + /** + * Fetches available audio formats from multiple clients for download selection. + * Returns all unique formats sorted by bitrate (highest first). + */ + suspend fun getAllAvailableAudioFormats( + videoId: String + ): Result> = runCatching { + Timber.tag(TAG).d("=== Fetching ALL audio formats for $videoId ===") + + val allClients = listOf(WEB_REMIX, TVHTML5, ANDROID_VR_1_43_32, IOS) + val allFormats = mutableListOf() + val seenItags = mutableSetOf() + + // Get signature timestamp once + val signatureTimestamp = getSignatureTimestampOrNull(videoId) + + // Check login status + val currentAuthCookie = YouTube.cookie + val isLoggedIn = currentAuthCookie != null && "SAPISID" in parseCookieString(currentAuthCookie) + val sessionId = if (isLoggedIn) YouTube.dataSyncId ?: YouTube.visitorData else YouTube.visitorData + + for (client in allClients) { + try { + if (client.loginRequired && !isLoggedIn) { + Timber.tag(TAG).d("Skipping ${client.clientName} - requires login") + continue + } + + // Generate PoToken for web clients + val poToken = if (client.useWebPoTokens && sessionId != null) { + poTokenGenerator.getWebClientPoToken(videoId, sessionId)?.playerRequestPoToken + } else null + + val response = YouTube.player( + videoId = videoId, + client = client, + signatureTimestamp = signatureTimestamp.timestamp, + poToken = poToken + ).getOrNull() + + if (response?.playabilityStatus?.status != "OK") { + Timber.tag(TAG).d("${client.clientName}: status=${response?.playabilityStatus?.status}") + continue + } + + val formats = response.streamingData?.adaptiveFormats + ?.filter { it.isAudio && it.isOriginal } + ?: continue + + for (format in formats) { + // Dedupe by itag (same format from different clients) + if (seenItags.contains(format.itag)) continue + seenItags.add(format.itag) + + val bitrateKbps = format.bitrate / 1000 + val codec = when { + format.mimeType.contains("opus") -> "OPUS" + format.mimeType.contains("mp4a") -> "M4A" + else -> format.mimeType.substringAfter("audio/").substringBefore(";").uppercase() + } + allFormats.add( + AudioFormatOption( + itag = format.itag, + bitrate = format.bitrate, + bitrateKbps = bitrateKbps, + mimeType = format.mimeType, + codec = codec, + ) + ) + Timber.tag(TAG).d(" ${client.clientName}: ${bitrateKbps}kbps $codec (itag=${format.itag})") + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to fetch from ${client.clientName}") + } + } + + // Sort by bitrate (highest first), then by codec (M4A before OPUS for same bitrate) + val sorted = allFormats.sortedWith(compareByDescending { it.bitrate }.thenBy { it.codec }) + Timber.tag(TAG).d("=== Total unique formats: ${sorted.size} ===") + sorted + } } diff --git a/app/src/main/res/values/metrolist_strings.xml b/app/src/main/res/values/metrolist_strings.xml index 85c349e44a..af656db5f1 100644 --- a/app/src/main/res/values/metrolist_strings.xml +++ b/app/src/main/res/values/metrolist_strings.xml @@ -827,4 +827,26 @@ Listen time: Convert Type more to narrow results (%d more) + + + Custom download path + Save downloads to a custom folder (e.g., SD card) + Select download folder + Reset to default (internal cache) + Current path: %s + No folder selected + Folder permission lost. Please re-select. + + + Choose download quality + Loading available qualities… + No formats available + + Metadata + BEST + High + Medium + Low + M4A 128kbps+ includes embedded cover art and metadata. Lower bitrate M4A and OPUS files will not have embedded metadata. + Swap download + Re-download with different quality