From 209b75a1ab4e31823de1aaf99f973c841791e1a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 06:38:59 +0000 Subject: [PATCH 1/2] Add m3u playlist export functionality This commit implements a robust, scalable, and accurate solution for exporting playlists to m3u format as requested in issue #112. Core Components: - M3uWriter: Generates m3u file content from song lists with proper formatting - Supports extended m3u format with #EXTINF metadata - Handles duration conversion (milliseconds to seconds) - Provides custom path resolver support for flexibility - PlaylistExporter: Service class for handling export operations - Multiple export strategies: to URI, to directory, or generate content - Best-effort path resolution for content:// URIs - Robust error handling with detailed error messages - Uses Android Storage Access Framework for file operations Repository Layer: - Added export methods to PlaylistRepository interface - Implemented in LocalPlaylistRepository with proper song retrieval UI Layer: - Added "Export playlist" menu item to playlist detail screen - Integrated with Android's file picker using ActivityResultContracts - User-friendly success/error messages with localized strings - Export uses playlist name as default filename Technical Approach: - Handles Android's content:// URI limitation by attempting to resolve real paths - Falls back to content URIs when real paths unavailable (known limitation) - Flexible path resolver allows callers to provide custom resolution logic - Comprehensive error handling for permissions, I/O, and invalid data Testing: - Added comprehensive unit tests for M3uWriter covering: - Basic single/multiple song export - Null handling for artist/track names - Custom path resolvers - Duration conversion accuracy - M3u format structure validation Addresses issue #112 --- .../detail/PlaylistDetailFragment.kt | 30 +++ .../detail/PlaylistDetailPresenter.kt | 41 +++ .../main/res/menu/menu_playlist_detail.xml | 4 + .../mediaprovider/M3uWriterTest.kt | 235 ++++++++++++++++++ .../simplecityapps/mediaprovider/M3uWriter.kt | 67 +++++ .../mediaprovider/PlaylistExporter.kt | 191 ++++++++++++++ .../playlists/PlaylistRepository.kt | 42 ++++ .../values/strings_media_provider_core.xml | 12 + .../repository/LocalPlaylistRepository.kt | 45 ++++ 9 files changed, 667 insertions(+) create mode 100644 android/app/src/test/java/com/simplecityapps/mediaprovider/M3uWriterTest.kt create mode 100644 android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt create mode 100644 android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt index 505032dd7..bf1a736ab 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt @@ -1,5 +1,6 @@ package com.simplecityapps.shuttle.ui.screens.library.playlists.detail +import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -7,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.Toolbar import androidx.core.content.res.ResourcesCompat @@ -71,6 +73,12 @@ class PlaylistDetailFragment : private var heroImage: ImageView by autoCleared() + private val createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("audio/x-mpegurl")) { uri -> + uri?.let { + presenter.exportPlaylistToUri(it) + } + } + // Lifecycle override fun onCreate(savedInstanceState: Bundle?) { @@ -114,6 +122,10 @@ class PlaylistDetailFragment : presenter.addToQueue(playlist) true } + R.id.export -> { + presenter.exportPlaylist() + true + } R.id.rename -> { EditTextAlertDialog .newInstance( @@ -312,6 +324,24 @@ class PlaylistDetailFragment : findNavController().popBackStack() } + override fun showExportSuccess() { + Toast.makeText(requireContext(), getString(com.simplecityapps.mediaprovider.R.string.playlist_export_success), Toast.LENGTH_SHORT).show() + } + + override fun showExportError(error: String) { + Toast.makeText( + requireContext(), + Phrase.from(requireContext(), com.simplecityapps.mediaprovider.R.string.playlist_export_failed) + .put("error_message", error) + .format(), + Toast.LENGTH_LONG + ).show() + } + + override fun showExportLocationPicker() { + createDocumentLauncher.launch("${playlist.name}.m3u") + } + // SongBinder.Listener Implementation private val songBinderListener = diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailPresenter.kt index 523bdd5fc..5f69d772f 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailPresenter.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailPresenter.kt @@ -1,8 +1,10 @@ package com.simplecityapps.shuttle.ui.screens.library.playlists.detail import android.content.Context +import android.net.Uri import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile +import com.simplecityapps.mediaprovider.PlaylistExporter import com.simplecityapps.mediaprovider.repository.playlists.PlaylistQuery import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository import com.simplecityapps.mediaprovider.repository.songs.SongRepository @@ -54,6 +56,12 @@ interface PlaylistDetailContract { fun showTagEditor(playlistSongs: List) fun dismiss() + + fun showExportSuccess() + + fun showExportError(error: String) + + fun showExportLocationPicker() } interface Presenter : BaseContract.Presenter { @@ -95,6 +103,10 @@ interface PlaylistDetailContract { from: Int, to: Int ) + + fun exportPlaylist() + + fun exportPlaylistToUri(uri: Uri) } } @@ -281,4 +293,33 @@ constructor( playlistRepository.updatePlaylistSongsSortOder(playlist.value, newSongs) } } + + override fun exportPlaylist() { + // Check if playlist has songs + if (playlistSongs.value.orEmpty().isEmpty()) { + view?.showExportError(context.getString(com.simplecityapps.mediaprovider.R.string.playlist_export_empty)) + return + } + // Trigger file picker in the view + view?.showExportLocationPicker() + } + + override fun exportPlaylistToUri(uri: Uri) { + launch { + withContext(Dispatchers.IO) { + when (val result = playlistRepository.exportPlaylistToUri(playlist.value, uri)) { + is PlaylistExporter.ExportResult.Success -> { + withContext(Dispatchers.Main) { + view?.showExportSuccess() + } + } + is PlaylistExporter.ExportResult.Failure -> { + withContext(Dispatchers.Main) { + view?.showExportError(result.error) + } + } + } + } + } + } } diff --git a/android/app/src/main/res/menu/menu_playlist_detail.xml b/android/app/src/main/res/menu/menu_playlist_detail.xml index 63ffd15b1..323de35cf 100644 --- a/android/app/src/main/res/menu/menu_playlist_detail.xml +++ b/android/app/src/main/res/menu/menu_playlist_detail.xml @@ -42,6 +42,10 @@ android:id="@+id/queue" android:title="@string/menu_title_add_to_queue" app:showAsAction="never" /> + String? = { "/sdcard/Music/test.mp3" } + + val result = m3uWriter.write(listOf(song), customResolver) + + assertNotNull(result) + assertTrue(result.contains("/sdcard/Music/test.mp3")) + } + + @Test + fun testWriteWithNullPathResolver() { + val song = createTestSong( + name = "Test Song", + artist = "Test Artist", + duration = 180000, + path = "/music/test.mp3" + ) + + // Resolver that returns null + val nullResolver: (Song) -> String? = { null } + + val result = m3uWriter.write(listOf(song), nullResolver) + + // Should return null when no valid songs found + assertNull(result) + } + + @Test + fun testWriteEmptyList() { + val result = m3uWriter.write(emptyList()) + + assertEquals("", result) + } + + @Test + fun testWriteWithMixedValidAndInvalidSongs() { + val songs = listOf( + createTestSong( + name = "Valid Song", + artist = "Artist", + duration = 180000, + path = "/music/valid.mp3" + ), + createTestSong( + name = "Invalid Song", + artist = "Artist", + duration = 180000, + path = "content://invalid" + ) + ) + + // Resolver that returns null for content URIs + val resolver: (Song) -> String? = { song -> + if (song.path.startsWith("content://")) null else song.path + } + + val result = m3uWriter.write(songs, resolver) + + assertNotNull(result) + assertTrue(result.contains("Valid Song")) + assertTrue(!result.contains("Invalid Song")) + } + + @Test + fun testDurationConversion() { + // Duration is in milliseconds in Song model, but should be in seconds in m3u + val song = createTestSong( + name = "Test", + artist = "Test", + duration = 125500, // 125.5 seconds in milliseconds + path = "/test.mp3" + ) + + val result = m3uWriter.write(listOf(song)) + + assertNotNull(result) + // Should be truncated to 125 seconds (integer division) + assertTrue(result.contains("#EXTINF:125,")) + } + + @Test + fun testFormatStructure() { + val song = createTestSong( + name = "Test Song", + artist = "Test Artist", + duration = 180000, + path = "/music/test.mp3" + ) + + val result = m3uWriter.write(listOf(song)) + + assertNotNull(result) + + val lines = result.lines() + assertEquals("#EXTM3U", lines[0]) + assertTrue(lines[1].isEmpty()) // Empty line after header + assertTrue(lines[2].startsWith("#EXTINF:")) + assertEquals("/music/test.mp3", lines[3]) + } + + // Helper function to create test songs + private fun createTestSong( + name: String?, + artist: String?, + duration: Int, + path: String + ): Song { + return Song( + id = 1, + name = name, + albumArtist = artist, + artists = artist?.let { listOf(it) } ?: emptyList(), + album = null, + track = null, + disc = null, + duration = duration, + date = null, + genres = emptyList(), + path = path, + size = 0, + mimeType = "audio/mpeg", + lastModified = null, + lastPlayed = null, + lastCompleted = null, + playCount = 0, + playbackPosition = 0, + blacklisted = false, + externalId = null, + mediaProvider = MediaProviderType.Shuttle, + replayGainTrack = null, + replayGainAlbum = null, + lyrics = null, + grouping = null, + bitRate = null, + bitDepth = null, + sampleRate = null, + channelCount = null + ) + } +} diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt new file mode 100644 index 000000000..e182b6580 --- /dev/null +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt @@ -0,0 +1,67 @@ +package com.simplecityapps.mediaprovider + +import com.simplecityapps.shuttle.model.Song + +/** + * M3uWriter generates m3u playlist file content from a list of songs. + * + * The generated m3u file follows the extended m3u format: + * - Starts with #EXTM3U header + * - Each song entry contains: + * - #EXTINF:, - + * - <file path> + */ +class M3uWriter { + + /** + * Generates m3u file content from a list of songs and their file paths. + * + * @param songs List of songs to include in the playlist + * @param pathResolver Function to resolve the file path for each song. + * Should return null if the path cannot be resolved. + * @return M3U file content as a string, or null if no valid songs were found + */ + fun write( + songs: List<Song>, + pathResolver: (Song) -> String? + ): String? { + val builder = StringBuilder() + builder.appendLine("#EXTM3U") + builder.appendLine() + + var validSongCount = 0 + + songs.forEach { song -> + val path = pathResolver(song) + if (path != null) { + // Convert duration from milliseconds to seconds + val durationSeconds = song.duration / 1000 + + // Format: artist - title + val artistName = song.friendlyArtistName ?: "Unknown Artist" + val trackName = song.name ?: "Unknown Track" + val info = "$artistName - $trackName" + + builder.appendLine("#EXTINF:$durationSeconds, $info") + builder.appendLine(path) + builder.appendLine() + + validSongCount++ + } + } + + // Return null if no valid songs were found + return if (validSongCount > 0) builder.toString() else null + } + + /** + * Simplified version that writes m3u content using song paths as-is. + * Useful when all songs already have valid file paths. + * + * @param songs List of songs to include in the playlist + * @return M3U file content as a string + */ + fun write(songs: List<Song>): String { + return write(songs) { it.path } ?: "" + } +} diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt new file mode 100644 index 000000000..275c2b915 --- /dev/null +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt @@ -0,0 +1,191 @@ +package com.simplecityapps.mediaprovider + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.simplecityapps.shuttle.model.Song +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.IOException + +/** + * PlaylistExporter handles exporting playlists to m3u files. + * + * This class provides multiple export strategies to handle Android's content:// URI system + * and the limitations around converting URIs to file paths. + */ +class PlaylistExporter( + private val context: Context, + private val m3uWriter: M3uWriter = M3uWriter() +) { + + /** + * Export playlist to an m3u file at the specified URI. + * + * This uses Android's Storage Access Framework and writes the playlist content + * to the provided document URI. + * + * @param playlistName Name of the playlist (used for logging) + * @param songs List of songs to export + * @param destinationUri URI where the m3u file should be written + * @param pathResolver Optional function to resolve file paths for songs. + * If null, uses the default resolver which attempts to extract + * real paths from content URIs. + * @return Result indicating success or failure with error message + */ + suspend fun exportToUri( + playlistName: String, + songs: List<Song>, + destinationUri: Uri, + pathResolver: ((Song) -> String?)? = null + ): ExportResult = withContext(Dispatchers.IO) { + try { + val resolver = pathResolver ?: ::resolvePathForSong + val m3uContent = m3uWriter.write(songs, resolver) + + if (m3uContent == null) { + return@withContext ExportResult.Failure("No valid songs found to export") + } + + context.contentResolver.openOutputStream(destinationUri)?.use { outputStream -> + outputStream.write(m3uContent.toByteArray(Charsets.UTF_8)) + outputStream.flush() + ExportResult.Success(destinationUri) + } ?: ExportResult.Failure("Could not open output stream for URI: $destinationUri") + } catch (e: IOException) { + Timber.e(e, "Failed to export playlist '$playlistName' to $destinationUri") + ExportResult.Failure("IO error: ${e.message}") + } catch (e: SecurityException) { + Timber.e(e, "Failed to export playlist '$playlistName' to $destinationUri (permission denied)") + ExportResult.Failure("Permission denied: ${e.message}") + } catch (e: Exception) { + Timber.e(e, "Unexpected error exporting playlist '$playlistName'") + ExportResult.Failure("Unexpected error: ${e.message}") + } + } + + /** + * Export playlist to a directory, creating a new m3u file. + * + * @param playlistName Name of the playlist (used for filename) + * @param songs List of songs to export + * @param directoryUri URI of the directory where the file should be created + * @param pathResolver Optional function to resolve file paths for songs + * @return Result indicating success or failure with error message + */ + suspend fun exportToDirectory( + playlistName: String, + songs: List<Song>, + directoryUri: Uri, + pathResolver: ((Song) -> String?)? = null + ): ExportResult = withContext(Dispatchers.IO) { + try { + val directory = DocumentFile.fromTreeUri(context, directoryUri) + ?: return@withContext ExportResult.Failure("Invalid directory URI") + + if (!directory.exists() || !directory.isDirectory) { + return@withContext ExportResult.Failure("Directory does not exist or is not accessible") + } + + // Sanitize playlist name for filename + val fileName = sanitizeFileName(playlistName) + ".m3u" + + // Check if file already exists and delete it + directory.findFile(fileName)?.delete() + + // Create new m3u file + val newFile = directory.createFile("audio/x-mpegurl", fileName) + ?: return@withContext ExportResult.Failure("Could not create file: $fileName") + + return@withContext exportToUri(playlistName, songs, newFile.uri, pathResolver) + } catch (e: Exception) { + Timber.e(e, "Failed to export playlist '$playlistName' to directory") + ExportResult.Failure("Error creating file: ${e.message}") + } + } + + /** + * Generate m3u content as a string without writing to a file. + * + * This can be used for sharing or when the caller wants to handle + * the file writing themselves. + * + * @param songs List of songs to export + * @param pathResolver Optional function to resolve file paths for songs + * @return M3U file content as a string, or null if no valid songs + */ + suspend fun generateM3uContent( + songs: List<Song>, + pathResolver: ((Song) -> String?)? = null + ): String? = withContext(Dispatchers.IO) { + val resolver = pathResolver ?: ::resolvePathForSong + m3uWriter.write(songs, resolver) + } + + /** + * Attempts to resolve a real file path for a song. + * + * This is a best-effort approach: + * 1. If the song path is already a file path (not content://), use it as-is + * 2. If it's a content:// URI, try to extract the real path + * 3. Fall back to using the content URI if no real path can be determined + * + * Note: Many m3u players may not be able to use content:// URIs, + * so this is a known limitation when exporting playlists on Android. + */ + private fun resolvePathForSong(song: Song): String? { + return when { + // If path doesn't start with content://, assume it's a real file path + !song.path.startsWith("content://") -> song.path + + // Try to extract real path from content URI + else -> { + tryGetRealPath(song.path) ?: song.path + } + } + } + + /** + * Attempts to get a real file path from a content URI. + * + * This is a best-effort method and may not work for all URIs, + * especially on Android 10+ with scoped storage. + * + * @param contentUri The content:// URI to resolve + * @return Real file path if available, null otherwise + */ + private fun tryGetRealPath(contentUri: String): String? { + return try { + val uri = Uri.parse(contentUri) + + // Try to get the document ID and extract path information + // This is a limited approach and won't work for all cases + val documentFile = DocumentFile.fromSingleUri(context, uri) + documentFile?.name?.let { fileName -> + // If we have a filename but no full path, at least use the filename + // This allows relative paths in m3u files which some players can handle + fileName + } + } catch (e: Exception) { + Timber.w(e, "Could not resolve real path for: $contentUri") + null + } + } + + /** + * Sanitizes a filename by removing or replacing invalid characters. + */ + private fun sanitizeFileName(name: String): String { + return name + .replace(Regex("[/\\\\:*?\"<>|]"), "_") + .trim() + .take(255) // Max filename length on most systems + } + + sealed class ExportResult { + data class Success(val uri: Uri) : ExportResult() + data class Failure(val error: String) : ExportResult() + } +} diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/playlists/PlaylistRepository.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/playlists/PlaylistRepository.kt index eb30effb3..324477ff3 100644 --- a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/playlists/PlaylistRepository.kt +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/playlists/PlaylistRepository.kt @@ -1,5 +1,7 @@ package com.simplecityapps.mediaprovider.repository.playlists +import android.net.Uri +import com.simplecityapps.mediaprovider.PlaylistExporter import com.simplecityapps.shuttle.model.MediaProviderType import com.simplecityapps.shuttle.model.Playlist import com.simplecityapps.shuttle.model.PlaylistSong @@ -70,6 +72,46 @@ interface PlaylistRepository { playlist: Playlist, externalId: String? ) + + /** + * Export a playlist to an m3u file at the specified URI. + * + * @param playlist The playlist to export + * @param destinationUri URI where the m3u file should be written + * @param pathResolver Optional function to resolve file paths for songs + * @return Result indicating success or failure + */ + suspend fun exportPlaylistToUri( + playlist: Playlist, + destinationUri: Uri, + pathResolver: ((Song) -> String?)? = null + ): PlaylistExporter.ExportResult + + /** + * Export a playlist to a directory, creating a new m3u file. + * + * @param playlist The playlist to export + * @param directoryUri URI of the directory where the file should be created + * @param pathResolver Optional function to resolve file paths for songs + * @return Result indicating success or failure + */ + suspend fun exportPlaylistToDirectory( + playlist: Playlist, + directoryUri: Uri, + pathResolver: ((Song) -> String?)? = null + ): PlaylistExporter.ExportResult + + /** + * Generate m3u content for a playlist as a string. + * + * @param playlist The playlist to export + * @param pathResolver Optional function to resolve file paths for songs + * @return M3U file content as a string, or null if no valid songs + */ + suspend fun generatePlaylistM3uContent( + playlist: Playlist, + pathResolver: ((Song) -> String?)? = null + ): String? } enum class PlaylistSortOrder : Serializable { diff --git a/android/mediaprovider/core/src/main/res/values/strings_media_provider_core.xml b/android/mediaprovider/core/src/main/res/values/strings_media_provider_core.xml index bae07fa59..f9e7639ea 100644 --- a/android/mediaprovider/core/src/main/res/values/strings_media_provider_core.xml +++ b/android/mediaprovider/core/src/main/res/values/strings_media_provider_core.xml @@ -90,4 +90,16 @@ <string name="media_import_directories_empty">No directories/files to scan</string> <!-- Shown when the S2 Media Provider is searching an m3u playlist for songs --> <string name="media_import_m3u_scan">Finding songs for <xliff:g example="playlist1.m3u" id="playlist_name">{playlist_name}</xliff:g></string> + <!-- Menu option to export a playlist to an m3u file --> + <string name="playlist_menu_export">Export playlist</string> + <!-- Title of dialog shown when exporting a playlist --> + <string name="playlist_export_dialog_title">Export playlist</string> + <!-- Message shown when a playlist is successfully exported --> + <string name="playlist_export_success">Playlist exported successfully</string> + <!-- Error message shown when playlist export fails --> + <string name="playlist_export_failed">Failed to export playlist: <xliff:g example="Permission denied" id="error_message">{error_message}</xliff:g></string> + <!-- Message shown when a playlist has no songs to export --> + <string name="playlist_export_empty">Playlist is empty. Nothing to export.</string> + <!-- Message shown when choosing where to save the exported playlist file --> + <string name="playlist_export_choose_location">Choose export location</string> </resources> diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalPlaylistRepository.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalPlaylistRepository.kt index ca848e738..2aa8c9d78 100644 --- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalPlaylistRepository.kt +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/repository/LocalPlaylistRepository.kt @@ -1,10 +1,12 @@ package com.simplecityapps.localmediaprovider.local.repository import android.content.Context +import android.net.Uri import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistDataDao import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistSongJoinDao import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistData import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSongJoin +import com.simplecityapps.mediaprovider.PlaylistExporter import com.simplecityapps.mediaprovider.repository.playlists.PlaylistQuery import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository import com.simplecityapps.mediaprovider.repository.playlists.comparator @@ -36,6 +38,10 @@ class LocalPlaylistRepository( private val playlistDataDao: PlaylistDataDao, private val playlistSongJoinDao: PlaylistSongJoinDao ) : PlaylistRepository { + private val playlistExporter: PlaylistExporter by lazy { + PlaylistExporter(context) + } + private val playlistsRelay: StateFlow<List<Playlist>?> by lazy { playlistDataDao .getAll() @@ -220,4 +226,43 @@ class LocalPlaylistRepository( ) ) } + + override suspend fun exportPlaylistToUri( + playlist: Playlist, + destinationUri: Uri, + pathResolver: ((Song) -> String?)? + ): PlaylistExporter.ExportResult { + val songs = getSongsForPlaylist(playlist).firstOrNull()?.map { it.song } ?: emptyList() + return playlistExporter.exportToUri( + playlistName = playlist.name, + songs = songs, + destinationUri = destinationUri, + pathResolver = pathResolver + ) + } + + override suspend fun exportPlaylistToDirectory( + playlist: Playlist, + directoryUri: Uri, + pathResolver: ((Song) -> String?)? + ): PlaylistExporter.ExportResult { + val songs = getSongsForPlaylist(playlist).firstOrNull()?.map { it.song } ?: emptyList() + return playlistExporter.exportToDirectory( + playlistName = playlist.name, + songs = songs, + directoryUri = directoryUri, + pathResolver = pathResolver + ) + } + + override suspend fun generatePlaylistM3uContent( + playlist: Playlist, + pathResolver: ((Song) -> String?)? + ): String? { + val songs = getSongsForPlaylist(playlist).firstOrNull()?.map { it.song } ?: emptyList() + return playlistExporter.generateM3uContent( + songs = songs, + pathResolver = pathResolver + ) + } } From 3eb3d72c1dc6d731a55318e561acc62bb8ab3345 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 16 Nov 2025 06:50:22 +0000 Subject: [PATCH 2/2] Apply ktlint formatting --- .../detail/PlaylistDetailFragment.kt | 1 - .../mediaprovider/M3uWriterTest.kt | 64 +++++++++---------- .../simplecityapps/mediaprovider/M3uWriter.kt | 4 +- .../mediaprovider/PlaylistExporter.kt | 47 ++++++-------- 4 files changed, 53 insertions(+), 63 deletions(-) diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt index bf1a736ab..0926cd69f 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/playlists/detail/PlaylistDetailFragment.kt @@ -1,6 +1,5 @@ package com.simplecityapps.shuttle.ui.screens.library.playlists.detail -import android.content.Intent import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater diff --git a/android/app/src/test/java/com/simplecityapps/mediaprovider/M3uWriterTest.kt b/android/app/src/test/java/com/simplecityapps/mediaprovider/M3uWriterTest.kt index 140cc2cb5..c41e82aa7 100644 --- a/android/app/src/test/java/com/simplecityapps/mediaprovider/M3uWriterTest.kt +++ b/android/app/src/test/java/com/simplecityapps/mediaprovider/M3uWriterTest.kt @@ -199,37 +199,35 @@ class M3uWriterTest { artist: String?, duration: Int, path: String - ): Song { - return Song( - id = 1, - name = name, - albumArtist = artist, - artists = artist?.let { listOf(it) } ?: emptyList(), - album = null, - track = null, - disc = null, - duration = duration, - date = null, - genres = emptyList(), - path = path, - size = 0, - mimeType = "audio/mpeg", - lastModified = null, - lastPlayed = null, - lastCompleted = null, - playCount = 0, - playbackPosition = 0, - blacklisted = false, - externalId = null, - mediaProvider = MediaProviderType.Shuttle, - replayGainTrack = null, - replayGainAlbum = null, - lyrics = null, - grouping = null, - bitRate = null, - bitDepth = null, - sampleRate = null, - channelCount = null - ) - } + ): Song = Song( + id = 1, + name = name, + albumArtist = artist, + artists = artist?.let { listOf(it) } ?: emptyList(), + album = null, + track = null, + disc = null, + duration = duration, + date = null, + genres = emptyList(), + path = path, + size = 0, + mimeType = "audio/mpeg", + lastModified = null, + lastPlayed = null, + lastCompleted = null, + playCount = 0, + playbackPosition = 0, + blacklisted = false, + externalId = null, + mediaProvider = MediaProviderType.Shuttle, + replayGainTrack = null, + replayGainAlbum = null, + lyrics = null, + grouping = null, + bitRate = null, + bitDepth = null, + sampleRate = null, + channelCount = null + ) } diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt index e182b6580..08911c256 100644 --- a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/M3uWriter.kt @@ -61,7 +61,5 @@ class M3uWriter { * @param songs List of songs to include in the playlist * @return M3U file content as a string */ - fun write(songs: List<Song>): String { - return write(songs) { it.path } ?: "" - } + fun write(songs: List<Song>): String = write(songs) { it.path } ?: "" } diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt index 275c2b915..269ab0fac 100644 --- a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt +++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/PlaylistExporter.kt @@ -1,14 +1,13 @@ package com.simplecityapps.mediaprovider -import android.content.ContentResolver import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.simplecityapps.shuttle.model.Song +import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber -import java.io.IOException /** * PlaylistExporter handles exporting playlists to m3u files. @@ -135,15 +134,13 @@ class PlaylistExporter( * Note: Many m3u players may not be able to use content:// URIs, * so this is a known limitation when exporting playlists on Android. */ - private fun resolvePathForSong(song: Song): String? { - return when { - // If path doesn't start with content://, assume it's a real file path - !song.path.startsWith("content://") -> song.path - - // Try to extract real path from content URI - else -> { - tryGetRealPath(song.path) ?: song.path - } + private fun resolvePathForSong(song: Song): String? = when { + // If path doesn't start with content://, assume it's a real file path + !song.path.startsWith("content://") -> song.path + + // Try to extract real path from content URI + else -> { + tryGetRealPath(song.path) ?: song.path } } @@ -156,22 +153,20 @@ class PlaylistExporter( * @param contentUri The content:// URI to resolve * @return Real file path if available, null otherwise */ - private fun tryGetRealPath(contentUri: String): String? { - return try { - val uri = Uri.parse(contentUri) - - // Try to get the document ID and extract path information - // This is a limited approach and won't work for all cases - val documentFile = DocumentFile.fromSingleUri(context, uri) - documentFile?.name?.let { fileName -> - // If we have a filename but no full path, at least use the filename - // This allows relative paths in m3u files which some players can handle - fileName - } - } catch (e: Exception) { - Timber.w(e, "Could not resolve real path for: $contentUri") - null + private fun tryGetRealPath(contentUri: String): String? = try { + val uri = Uri.parse(contentUri) + + // Try to get the document ID and extract path information + // This is a limited approach and won't work for all cases + val documentFile = DocumentFile.fromSingleUri(context, uri) + documentFile?.name?.let { fileName -> + // If we have a filename but no full path, at least use the filename + // This allows relative paths in m3u files which some players can handle + fileName } + } catch (e: Exception) { + Timber.w(e, "Could not resolve real path for: $contentUri") + null } /**