diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index be8feaa..6591e94 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,4 +15,5 @@ You are reviewing code for **Cygnus Player**, a minimalist, high-performance loc 2. **Forward-Only Shuffling:** When shuffling, any tracks appearing before the current playback anchor in a folder/album context must be permanently discarded from the new queue array. 3. **Smart ReplayGain:** Verify that volume multipliers dynamically apply `ALBUM_GAIN` for sequential modes and `TRACK_GAIN` for randomized modes. 4. **Jetpack Compose Only:** Enforce that all UI additions use Jetpack Compose and adhere to the high-contrast Monokai Pro color palette (prioritize Purple, Blue/Cyan, and Orange for color-blind accessibility). -5. **Foreground Service:** Ensure any modifications to `MediaSessionService` cleanly declare the `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` to satisfy Android 14+ bounds. +5. **Foreground Service:** Ensure any modifications to `MediaSessionService` cleanly declare the `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` to satisfy Android 16+ security boundaries. +6. **Version Consistency:** Verify that `VERSION_NAME` and `VERSION_CODE` updates are perfectly synchronized across all 3 source files: `version.properties`, `com.festerhead.cygnusplayer.VersionInfo.kt`, and `CHANGELOG.md`. diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index cf16e57..9346e6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ You are an expert Android Engineer specializing in low-overhead audio architectu - **No Automated Git Operations:** The AI Agent is strictly prohibited from executing `git commit` or `git push`. - **Incremental Changes:** Work will be broken down into small, logical increments. The Agent will pause after each increment to allow the user to review, commit, and push via the Android Studio UI. - **Branching Strategy:** All feature development and AI-assisted coding must target the `develop` branch. Pull requests from `develop` to `main` are used to trigger signed Release builds via GitHub Actions. -- **Versioning Strategy:** The `version.properties` file in the project root is the absolute single source of truth for the app's version. Before any code is merged into `main` for a release, the AI Agent must verify and update `VERSION_NAME` and `VERSION_CODE` in this file. The GitHub Action will use this to automatically publish a public Release. +- **Versioning Strategy:** The `version.properties` file in the project root is the source of truth for the app's version. To avoid `BuildConfig` instability in experimental environments, a manual `VersionInfo.kt` object must also be maintained in sync with this file. Before any code is merged into `main` for a release, the AI Agent must verify and update `VERSION_NAME` and `VERSION_CODE` in both `version.properties` and `app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt`. The GitHub Action will use `version.properties` to automatically publish a public Release. - **Static Analysis:** The Agent must run `analyze_file` (IDE Inspections) on every modified file to identify and resolve unused code, deprecations, and style issues before task completion. - **Documentation Standards:** Every new class, interface, method, and function must include proper KDoc documentation. KDoc must clearly explain the purpose, parameters (`@param`), and return values (`@return`). For Room entities, explain the role of each column. - **AI Commit Messages:** The user will utilize the Android Studio AI-generated commit message feature. diff --git a/CHANGELOG.md b/CHANGELOG.md index 344181e..fcc0d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ Release policy: ## [Unreleased] +## [1.0.1] - 2026-07-25 + +### Added +- Minimalist Settings screen with version info and project links. +- "Reset Music Root Folder" utility for easier storage reconfiguration. +- Basic database diagnostics (Track and Playlist counts). + +### Fixed +- Resolved Android 17.1 (API 37) binder deadlock by deferring startup permission requests. +- Improved UI test stability with robust `waitUntil` synchronization. +- Stabilized database stress tests for virtualized emulator environments. + ## [1.0.0] - 2026-07-23 - Initial version diff --git a/README.md b/README.md index e617137..2f11954 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,25 @@ Modern commercial streaming apps and feature-bloated players treat music like a - **Minted Identity**: Playlists are assigned a shuffle strategy at the moment of creation, ensuring deterministic behavior for specialized collections (e.g., a dedicated "Chaos" vs. "Album" playlist). - **No-Skip Philosophy**: Designed for the "full experience" listener—no forward/back controls. Playback is an immutable journey once started. - **Dynamic ReplayGain**: "Smart" real-time volume normalization. Automatically applies `ALBUM_GAIN` for sequential flows and `TRACK_GAIN` for randomized tracks. -- **Modern Android Core**: Built for Android 17 (API 37) using Jetpack Compose, Media3 (ExoPlayer), and Jetpack Glance. +- **Persistent State Across Updates**: Per-playlist state (last played `sequence_id`, exact track position, active `ShuffleMode`, and exact shuffled order mapping) is stored in the Room database, ensuring all playback positions and custom shuffle orders are retained across application restarts and updates. +- **Modern Android Core**: Built for Android 16 (API 36) using Jetpack Compose, Media3 (ExoPlayer), and Jetpack Glance. + +## 📱 Application Screenshots + +### Initial Setup & Storage Access +| App Launch | Select Music Root | SAF Root Permission | Media Permission | +| :-: | :-: | :-: | :-: | +| App Installed | Select Root Folder | Root Access Granted | Media Permission | + +### Playlist Management & Minting +| Empty Playlist History | Mint Shuffle Mode | Active Playlist History | +| :-: | :-: | :-: | +| No Playlists Loaded | Select Shuffle Mode | Loaded Playlists | + +### Playback, Widget & Configuration +| Minimalist Now Playing | Home Screen Widget | Settings & Diagnostics | +| :-: | :-: | :-: | +| Now Playing | Home Widget | Configuration | ## 📁 Storage & Scoped Storage Compliance @@ -41,7 +59,7 @@ This project is developed and managed using Google AI models. The architecture, ## 🛠 Tech Stack -- **Target Platform**: Android 17 (API Level 37) +- **Target Platform**: Android 16 (API Level 36) - **UI Framework**: Jetpack Compose - **Playback Engine**: `androidx.media3:media3-exoplayer` & `MediaSessionService` - **Database**: `androidx.room` with SQLite indexing on `sequence_id` and `file_path` @@ -96,7 +114,7 @@ Cygnus Player utilizes a custom-designed **Adaptive Icon** that reflects the cos - [x] Folder-Sequential Logic: History-aware directory shuffling (24-folder buffer). - [x] ReplayGain Controller: "Smart" gain switching logic (Album vs. Track). - [x] **Playback**: Media3 Service integration with ReplayGain and Audio Focus. - - [x] `MediaSessionService`: Foreground service with Android 17 security bounds. + - [x] `MediaSessionService`: Foreground service with Android 16 security bounds. - [x] ExoPlayer Core: Gapless transition and volume normalization. - [x] Lazy Queue Controller: Sliding window logic for $O(1)$ memory playback. - [x] System Integration: Audio Focus and `BECOMING_NOISY` handling. @@ -112,45 +130,44 @@ Cygnus Player utilizes a custom-designed **Adaptive Icon** that reflects the cos - [x] Position Persistence: Per-playlist millisecond-accurate resumption. - [x] Smart Bluetooth: Automated playback resumption upon device reconnection. -## 🧪 Running Tests - -To maintain "Zero-Manual-Discovery" of bugs, Cygnus Player uses a dual-layered testing strategy. For more details, see [TESTING_STRATEGY.md](TESTING_STRATEGY.md). - -### 1. Unit Tests (Logic-First) +## 🧪 High-Efficiency Workflows -Run these for sub-second validation of parsers, mapping logic, and shuffle algorithms on the JVM. +To maintain "Zero-Manual-Discovery" of bugs while bypassing framework-level environmental issues (like the Android 17.1 binder deadlock), use the following PowerShell aliases. +### 1. Alias Setup +Add these to your PowerShell `$PROFILE` for maximum productivity: ```powershell -./gradlew test +function ctest { + adb shell input keyevent 224; adb shell wm dismiss-keyguard + ./gradlew test connectedDebugAndroidTest +} +function crun { + adb shell input keyevent 224; adb shell wm dismiss-keyguard + ./gradlew installRelease + adb shell am start -n com.festerhead.cygnusplayer/.MainActivity +} +function cdebug { + adb shell input keyevent 224; adb shell wm dismiss-keyguard + ./gradlew installDebug + adb shell am start -n com.festerhead.cygnusplayer/.MainActivity +} ``` -### 2. Instrumented Tests (Device Validation) - -Run these to verify Room database integrity and physical media extraction on a connected device or emulator. - +### 2. Manual Commands +If you prefer the standard Gradle tasks, ensure the emulator is **awake and unlocked** first: ```powershell -./gradlew connectedDebugAndroidTest -``` +# Wake up and unlock +adb shell input keyevent 224; adb shell wm dismiss-keyguard -> [!TIP] -> You can run both suites sequentially using: -> -> ```powershell -> ./gradlew test connectedDebugAndroidTest -> ``` -> -> If Gradle skips tests due to UP-TO-DATE checks, you can force execution using: -> -> ```powershell -> ./gradlew clean test # Deletes build artifacts and runs everything fresh -> ./gradlew test --rerun-tasks # Runs all test tasks regardless of cache -> ``` +# Run full suite +./gradlew test connectedDebugAndroidTest +``` ## 🚀 Deployment & CI/CD ### Local Deployment -To build the APK and install the app on your connected device or emulator from the CLI: +The fastest way to deploy is using the **`crun`** or **`cdebug`** aliases defined above. Alternatively, use the manual commands: ```powershell ./gradlew :app:assembleDebug @@ -171,10 +188,22 @@ Cygnus Player utilizes GitHub Actions for continuous integration and delivery: #### Versioning -The single source of truth for the app's version is the `version.properties` file in the project root. Before merging to `main` to trigger a release, ensure you update this file: +The app's version is maintained in two locations for build stability: +1. **`version.properties`**: The primary source of truth used by Gradle and CI/CD. +2. **`VersionInfo.kt`**: A static object in the source code used by the UI to avoid `BuildConfig` race conditions in experimental environments. + +Before merging to `main` to trigger a release, ensure both files are updated: ```properties -VERSION_NAME=1.0.0 -VERSION_CODE=1 +# version.properties +VERSION_NAME=1.0.1 +VERSION_CODE=2 +``` +```kotlin +// VersionInfo.kt +object VersionInfo { + const val VERSION_NAME = "1.0.1" + const val VERSION_CODE = 2 +} ``` Gradle will automatically inject these values into the APK, and the GitHub Action will parse them to name your automated Release! diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d274142..8d65477 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,6 +67,7 @@ android { buildFeatures { compose = true + buildConfig = true } @Suppress("UnstableApiUsage") diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt index 3dcdf5f..f441e28 100644 --- a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt +++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/NowPlayingScreenTest.kt @@ -28,6 +28,10 @@ class NowPlayingScreenTest { } } + composeTestRule.waitUntil(20000) { + composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("No track playing")).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("No track playing").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Back to Playlists").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Play").assertIsDisplayed() diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt index dbb919d..97cbd54 100644 --- a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt +++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistNavigationTest.kt @@ -1,6 +1,5 @@ package com.festerhead.cygnusplayer.ui.screens -import android.app.Application import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,8 +41,8 @@ class PlaylistNavigationTest { ) val pickerViewModel = PlaylistPickerViewModel( - ApplicationProvider.getApplicationContext(), - playlistStateDao + ApplicationProvider.getApplicationContext(), + playlistStateDao, ) // We use a state to control navigation in the test @@ -53,9 +52,14 @@ class PlaylistNavigationTest { composeTestRule.setContent { CygnusPlayerTheme { if (currentScreen == "PICKER") { - PlaylistPickerScreen(viewModel = pickerViewModel) { path -> - selectedPlaylistPath = path - currentScreen = "NOW_PLAYING" + PlaylistPickerScreen( + viewModel = pickerViewModel, + onPlaylistSelected = { path -> + selectedPlaylistPath = path + currentScreen = "NOW_PLAYING" + } + ) { + currentScreen = "NOW_PLAYING" // Simplified for lambda } } else { // Inject a NowPlayingViewModel that reflects the selected playlist @@ -74,10 +78,16 @@ class PlaylistNavigationTest { } // 1. Select the playlist + composeTestRule.waitUntil(20000) { + composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Rush.m3u8")).fetchSemanticsNodes().isNotEmpty() + } composeTestRule.onNodeWithText("Rush.m3u8").performClick() composeTestRule.waitForIdle() // 2. Verify navigation and playlist name display + composeTestRule.waitUntil(20000) { + composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Rush.m3u8")).fetchSemanticsNodes().size == 1 + } composeTestRule.onNodeWithText("Rush.m3u8").assertIsDisplayed() // 3. Verify that the correct shuffle mode is initialized and rendered on the screen diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt index 53cfced..2489820 100644 --- a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt +++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreenTest.kt @@ -1,6 +1,5 @@ package com.festerhead.cygnusplayer.ui.screens -import android.app.Application import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText @@ -36,11 +35,11 @@ class PlaylistPickerScreenTest { // Mock the initial history load coEvery { playlistStateDao.getAllStates() } returns listOf( PlaylistStateEntity("/storage/music/Rush.m3u8", 0, ShuffleMode.SEQUENTIAL, 1000L), - PlaylistStateEntity("/storage/music/Hemispheres.m3u", 2112, ShuffleMode.TRACK_RANDOM, 2000L) + PlaylistStateEntity("/storage/music/Hemispheres.m3u", 2112, ShuffleMode.TRACK_RANDOM, 2000L), ) viewModel = PlaylistPickerViewModel( - ApplicationProvider.getApplicationContext(), + ApplicationProvider.getApplicationContext(), playlistStateDao ) } @@ -49,10 +48,17 @@ class PlaylistPickerScreenTest { fun testBrandingAndGuidance() { composeTestRule.setContent { CygnusPlayerTheme { - PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {}) + PlaylistPickerScreen( + viewModel = viewModel, + onPlaylistSelected = {} + ) {} } } + composeTestRule.waitUntil(20000) { + composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Cygnus Player")).fetchSemanticsNodes().isNotEmpty() + } + // Verify branding title composeTestRule.onNodeWithText("Cygnus Player").assertIsDisplayed() @@ -64,10 +70,17 @@ class PlaylistPickerScreenTest { fun testHistoryListRendering() { composeTestRule.setContent { CygnusPlayerTheme { - PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {}) + PlaylistPickerScreen( + viewModel = viewModel, + onPlaylistSelected = {} + ) {} } } + composeTestRule.waitUntil(20000) { + composeTestRule.onAllNodes(androidx.compose.ui.test.hasText("Rush.m3u8")).fetchSemanticsNodes().isNotEmpty() + } + // Verify that filenames (from paths) are rendered composeTestRule.onNodeWithText("Rush.m3u8").assertIsDisplayed() composeTestRule.onNodeWithText("Hemispheres.m3u").assertIsDisplayed() @@ -81,7 +94,10 @@ class PlaylistPickerScreenTest { fun testErrorSnackbarVisibility() { composeTestRule.setContent { CygnusPlayerTheme { - PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {}) + PlaylistPickerScreen( + viewModel = viewModel, + onPlaylistSelected = {} + ) {} } } diff --git a/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreenTest.kt new file mode 100644 index 0000000..b7a39f9 --- /dev/null +++ b/app/src/androidTest/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreenTest.kt @@ -0,0 +1,36 @@ +package com.festerhead.cygnusplayer.ui.settings + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.festerhead.cygnusplayer.ui.theme.CygnusPlayerTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testSettingsScreenRendersCorrectly() { + composeTestRule.setContent { + CygnusPlayerTheme { + SettingsScreen(onNavigateBack = {}) + } + } + + // Verify sections + composeTestRule.onNodeWithText("CONFIGURATION").assertIsDisplayed() + composeTestRule.onNodeWithText("DIAGNOSTICS").assertIsDisplayed() + composeTestRule.onNodeWithText("ABOUT").assertIsDisplayed() + + // Verify specific items + composeTestRule.onNodeWithText("Reset Music Root Folder").assertIsDisplayed() + composeTestRule.onNodeWithText("GitHub Repository").assertIsDisplayed() + composeTestRule.onNodeWithText("MIT License").assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt b/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt index 9574b07..6c49118 100644 --- a/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt +++ b/app/src/main/java/com/festerhead/cygnusplayer/MainActivity.kt @@ -1,22 +1,20 @@ package com.festerhead.cygnusplayer -import android.Manifest import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.activity.viewModels +import androidx.compose.runtime.* import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewmodel.compose.viewModel import com.festerhead.cygnusplayer.ui.screens.NowPlayingScreen import com.festerhead.cygnusplayer.ui.screens.PlaylistPickerScreen +import com.festerhead.cygnusplayer.ui.settings.SettingsScreen import com.festerhead.cygnusplayer.ui.theme.CygnusPlayerTheme import com.festerhead.cygnusplayer.ui.viewmodel.NowPlayingViewModel +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds /** * The main activity of the Cygnus Player application. @@ -26,59 +24,66 @@ import com.festerhead.cygnusplayer.ui.viewmodel.NowPlayingViewModel */ class MainActivity : ComponentActivity() { + private val viewModel: PlaylistPickerViewModel by viewModels { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + val db = (application as CygnusApplication).database + return PlaylistPickerViewModel(application, db.playlistStateDao()) as T + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val db = (application as CygnusApplication).database - setContent { CygnusPlayerTheme { - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { /* Handle result */ } + var isReady by remember { mutableStateOf(value = false) } LaunchedEffect(Unit) { - permissionLauncher.launch(Manifest.permission.READ_MEDIA_AUDIO) + // Small delay to ensure system binder is stable on Android 17.1 + delay(500.milliseconds) + isReady = true } - val viewModel: PlaylistPickerViewModel = viewModel( - factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return PlaylistPickerViewModel(application, db.playlistStateDao()) as T - } - }, - ) - - val pickerUiState by viewModel.uiState.collectAsState() + if (isReady) { + val pickerUiState by viewModel.uiState.collectAsState() + var showSettings by remember { mutableStateOf(value = false) } - if (pickerUiState.activePlaylistPath != null) { - val nowPlayingViewModel: NowPlayingViewModel = viewModel() - LaunchedEffect(pickerUiState.activePlaylistPath, pickerUiState.activeShuffleMode) { - val path = pickerUiState.activePlaylistPath!! - val decodedPath = try { - android.net.Uri.decode(path) - } catch (_: Exception) { - path + if (showSettings) { + SettingsScreen(onNavigateBack = { showSettings = false }) + } else if (pickerUiState.activePlaylistPath != null) { + val nowPlayingViewModel: NowPlayingViewModel = androidx.lifecycle.viewmodel.compose.viewModel() + LaunchedEffect(pickerUiState.activePlaylistPath, pickerUiState.activeShuffleMode) { + val path = pickerUiState.activePlaylistPath!! + val decodedPath = try { + android.net.Uri.decode(path) + } catch (_: Exception) { + path + } + val name = decodedPath.substringAfterLast("/").substringAfterLast("\\") + nowPlayingViewModel.initialize( + name, + pickerUiState.activeShuffleMode, + ) } - val name = decodedPath.substringAfterLast("/").substringAfterLast("\\") - nowPlayingViewModel.initialize( - name, - pickerUiState.activeShuffleMode, - ) - } - NowPlayingScreen( - viewModel = nowPlayingViewModel, - ) { - viewModel.setActivePlaylist(null) - } - } else { - PlaylistPickerScreen(viewModel = viewModel) { path -> - viewModel.onPlaylistClicked(this, path) { - // Handled by reactive state + NowPlayingScreen( + viewModel = nowPlayingViewModel, + ) { + viewModel.setActivePlaylist(null) } + } else { + PlaylistPickerScreen( + viewModel = viewModel, + onPlaylistSelected = { path -> + viewModel.onPlaylistClicked(this, path) { + // Handled by reactive state + } + }, + ) { showSettings = true } } } } diff --git a/app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt b/app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt new file mode 100644 index 0000000..590ed19 --- /dev/null +++ b/app/src/main/java/com/festerhead/cygnusplayer/VersionInfo.kt @@ -0,0 +1,12 @@ +package com.festerhead.cygnusplayer + +/** + * Static version information for the Cygnus Player application. + * + * This object is used instead of the auto-generated BuildConfig class to ensure + * build stability across environments where the Android toolchain may be inconsistent. + */ +object VersionInfo { + const val VERSION_NAME = "1.0.1" + const val VERSION_CODE = 2 +} diff --git a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt index 1b1bf7a..38bb8ae 100644 --- a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt +++ b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/PlaylistStateDao.kt @@ -35,4 +35,10 @@ interface PlaylistStateDao { */ @Query("DELETE FROM playlist_states WHERE m3u_path = :path") suspend fun deleteState(path: String) + + /** + * Returns the total number of saved playlists in history. + */ + @Query("SELECT COUNT(*) FROM playlist_states") + suspend fun getPlaylistCount(): Int } diff --git a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt index bae6cd2..6d7d118 100644 --- a/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt +++ b/app/src/main/java/com/festerhead/cygnusplayer/data/daos/TrackDao.kt @@ -58,4 +58,10 @@ interface TrackDao { */ @Query("DELETE FROM tracks") suspend fun deleteAll() + + /** + * Returns the total number of unique tracks in the database. + */ + @Query("SELECT COUNT(*) FROM tracks") + suspend fun getTrackCount(): Int } diff --git a/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt b/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt index e94dffc..df43021 100644 --- a/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt +++ b/app/src/main/java/com/festerhead/cygnusplayer/ui/screens/PlaylistPickerScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -36,12 +37,18 @@ import com.festerhead.cygnusplayer.ui.theme.MonokaiGreen @Composable fun PlaylistPickerScreen( viewModel: PlaylistPickerViewModel, - onPlaylistSelected: (String) -> Unit + onPlaylistSelected: (String) -> Unit, + onSettingsClicked: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + // Permission Launcher moved here to prevent startup deadlock on Android 17.1 + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* Permission results are handled by system; we check again on button click if needed */ } + // Launcher for picking the Music Root folder val folderLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree(), @@ -77,8 +84,7 @@ fun PlaylistPickerScreen( MintingDialog( fileName = cleanPendingName, onModeSelected = { viewModel.mintPlaylist(it) }, - onDismiss = { viewModel.cancelMinting() } - ) + ) { viewModel.cancelMinting() } } Scaffold( @@ -109,20 +115,36 @@ fun PlaylistPickerScreen( .padding(innerPadding) .fillMaxSize(), ) { - Text( - text = "Cygnus Player", + Row( modifier = Modifier + .fillMaxWidth() .statusBarsPadding() .padding(start = 24.dp, end = 24.dp, top = 32.dp, bottom = 8.dp), - color = MaterialTheme.colorScheme.primary, - fontSize = 40.sp, - fontWeight = FontWeight.Black, - letterSpacing = 4.sp, - ) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Cygnus Player", + color = MaterialTheme.colorScheme.primary, + fontSize = 40.sp, + fontWeight = FontWeight.Black, + letterSpacing = 4.sp, + ) + IconButton(onClick = onSettingsClicked) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } // Library Root Warning/Setup if (uiState.libraryRootUri == null) { - LibraryRootPrompt { folderLauncher.launch(null) } + LibraryRootPrompt { + permissionLauncher.launch(android.Manifest.permission.READ_MEDIA_AUDIO) + folderLauncher.launch(null) + } } Text( @@ -287,18 +309,15 @@ fun MintingDialog( MintingButton( label = "SEQUENTIAL", color = MaterialTheme.colorScheme.primary, - onClick = { onModeSelected(ShuffleMode.SEQUENTIAL) }, - ) + ) { onModeSelected(ShuffleMode.SEQUENTIAL) } MintingButton( label = "ALBUM SHUFFLE", color = MaterialTheme.colorScheme.secondary, - onClick = { onModeSelected(ShuffleMode.RANDOM_FOLDER_SEQUENTIAL) }, - ) + ) { onModeSelected(ShuffleMode.RANDOM_FOLDER_SEQUENTIAL) } MintingButton( label = "CHAOS (RANDOM)", color = MaterialTheme.colorScheme.tertiary, - onClick = { onModeSelected(ShuffleMode.TRACK_RANDOM) }, - ) + ) { onModeSelected(ShuffleMode.TRACK_RANDOM) } } }, confirmButton = { diff --git a/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..54a14b5 --- /dev/null +++ b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsScreen.kt @@ -0,0 +1,137 @@ +package com.festerhead.cygnusplayer.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.festerhead.cygnusplayer.VersionInfo + +/** + * Minimalist Settings screen for project information and basic configuration. + * + * @param onNavigateBack Callback to return to the previous screen. + * @param viewModel The [SettingsViewModel] instance. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + viewModel: SettingsViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + val uriHandler = LocalUriHandler.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.primary, + ) + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Configuration Section + SettingsSection(title = "Configuration") { + Button( + onClick = { viewModel.resetRootFolder() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) + ) { + Text("Reset Music Root Folder", color = MaterialTheme.colorScheme.background) + } + Text( + text = "Forces the app to prompt for Music folder access on next launch.", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + + // Diagnostics Section + SettingsSection(title = "Diagnostics") { + DiagnosticRow(label = "Total Tracks Mapped", value = uiState.totalTracks.toString()) + DiagnosticRow(label = "Saved Playlists", value = uiState.totalPlaylists.toString()) + } + + // About Section + SettingsSection(title = "About") { + Text( + text = "Version: ${VersionInfo.VERSION_NAME} (${VersionInfo.VERSION_CODE})", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { uriHandler.openUri("https://github.com/festerhead/cygnus-player") }) { + Text("GitHub Repository", color = MaterialTheme.colorScheme.secondary) + } + TextButton(onClick = { uriHandler.openUri("https://opensource.org/licenses/MIT") }) { + Text("MIT License", color = MaterialTheme.colorScheme.tertiary) + } + } + } + } +} + +@Composable +fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Column { + Text( + text = title.uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Black, + letterSpacing = 2.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + content() + } + } + } +} + +@Composable +fun DiagnosticRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, color = MaterialTheme.colorScheme.onSurface) + Text(text = value, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + } +} diff --git a/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..68be45f --- /dev/null +++ b/app/src/main/java/com/festerhead/cygnusplayer/ui/settings/SettingsViewModel.kt @@ -0,0 +1,59 @@ +package com.festerhead.cygnusplayer.ui.settings + +import android.app.Application +import android.content.Context +import androidx.core.content.edit +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.festerhead.cygnusplayer.CygnusApplication +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Diagnostic data for the Settings screen. + * + * @property totalTracks Total unique tracks indexed in the database. + * @property totalPlaylists Total number of playlists in history. + */ +data class SettingsUiState( + val totalTracks: Int = 0, + val totalPlaylists: Int = 0, +) + +/** + * ViewModel for the Settings screen. + * Handles configuration resets and diagnostic data retrieval. + */ +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadDiagnostics() + } + + /** + * Refreshes the database diagnostic counts. + */ + fun loadDiagnostics() { + viewModelScope.launch { + val app = getApplication() + val tracks = app.database.trackDao().getTrackCount() + val playlists = app.database.playlistStateDao().getPlaylistCount() + _uiState.update { it.copy(totalTracks = tracks, totalPlaylists = playlists) } + } + } + + /** + * Clears the library root URI from shared preferences, forcing a re-link. + */ + fun resetRootFolder() { + val app = getApplication() + val prefs = app.getSharedPreferences("cygnus_prefs", Context.MODE_PRIVATE) + prefs.edit { remove("library_root") } + } +} diff --git a/doc/auto_integration_strategy.md b/doc/auto_integration_strategy.md deleted file mode 100644 index 76c299a..0000000 --- a/doc/auto_integration_strategy.md +++ /dev/null @@ -1,18 +0,0 @@ -# Architectural Strategy: Android Auto Integration - -## Purpose -To provide a safety-compliant, minimalist "hands-free" interface for car head units while strictly adhering to our minimalist UI philosophy. - -## Strategy: Minimalist `MediaLibraryService` -Instead of a full-featured library browser, we will implement a "Thin-Interface" `MediaLibraryService`: - -1. **Root Node**: Return a single virtual root node labeled "Playlists". -2. **Children**: Map the current playlist history into the library hierarchy so the car head unit can navigate between your active M3U sessions. -3. **No-Skip Compliance**: The Android Auto interface will honor the standard `MediaSession` transport controls. We will implement them to match our minimalist philosophy: - * **Play/Pause**: Supported. - * **Skip Next/Previous**: Explicitly **Disabled** or mapped to "Play Next Folder/Playlist" to respect the "Forward-Only" shuffle principle. -4. **UI Minimalization**: We will provide basic track information (`title`, `artist`) to the car display, ensuring that the driver sees exactly what is on the phone screen without adding extra metadata or browser bloat. - -## Implementation Requirements -- **Manifest**: Must include `` for `androidx.media3.session.MediaLibraryService`. -- **ExoPlayer Integration**: The service must share the same `ExoPlayer` instance used by our `MediaSessionService` to ensure playback state consistency between the car head unit and the phone. diff --git a/docs/screenshots/01 - app installed.png b/docs/screenshots/01 - app installed.png new file mode 100644 index 0000000..7855dee Binary files /dev/null and b/docs/screenshots/01 - app installed.png differ diff --git a/docs/screenshots/02 - select root folder.png b/docs/screenshots/02 - select root folder.png new file mode 100644 index 0000000..2537f4d Binary files /dev/null and b/docs/screenshots/02 - select root folder.png differ diff --git a/docs/screenshots/03 - root folder access.png b/docs/screenshots/03 - root folder access.png new file mode 100644 index 0000000..9bbe798 Binary files /dev/null and b/docs/screenshots/03 - root folder access.png differ diff --git a/docs/screenshots/04 - app permissions.png b/docs/screenshots/04 - app permissions.png new file mode 100644 index 0000000..ed3091c Binary files /dev/null and b/docs/screenshots/04 - app permissions.png differ diff --git a/docs/screenshots/05 - no playlists loaded.png b/docs/screenshots/05 - no playlists loaded.png new file mode 100644 index 0000000..4b11b17 Binary files /dev/null and b/docs/screenshots/05 - no playlists loaded.png differ diff --git a/docs/screenshots/06 - select shuffle mode.png b/docs/screenshots/06 - select shuffle mode.png new file mode 100644 index 0000000..0868991 Binary files /dev/null and b/docs/screenshots/06 - select shuffle mode.png differ diff --git a/docs/screenshots/07 - one of each mode loaded.png b/docs/screenshots/07 - one of each mode loaded.png new file mode 100644 index 0000000..671d289 Binary files /dev/null and b/docs/screenshots/07 - one of each mode loaded.png differ diff --git a/docs/screenshots/08 - now playing.png b/docs/screenshots/08 - now playing.png new file mode 100644 index 0000000..be52a8d Binary files /dev/null and b/docs/screenshots/08 - now playing.png differ diff --git a/docs/screenshots/09 - widget.png b/docs/screenshots/09 - widget.png new file mode 100644 index 0000000..a60a5ea Binary files /dev/null and b/docs/screenshots/09 - widget.png differ diff --git a/docs/screenshots/10 - configuration.png b/docs/screenshots/10 - configuration.png new file mode 100644 index 0000000..056ac33 Binary files /dev/null and b/docs/screenshots/10 - configuration.png differ diff --git a/gradle.properties b/gradle.properties index 86d4b81..b3a8a0d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,7 @@ # --- Gradle Configuration --- # Specifies the JVM arguments used for the Gradle Daemon. # -Xmx2g: Increases the maximum heap size to 2GB. -# -Dcom.android.tools.r8.apiLevelOverride=37: Forces R8 and D8 to optimize for API 37. -org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dcom.android.tools.r8.apiLevelOverride=37 -Dcom.android.tools.d8.apiLevelOverride=37 +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError # Enables the Gradle Configuration Cache, which significantly speeds up builds by reusing the results of the configuration phase. org.gradle.configuration-cache=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 6c1139e..418e7d1 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,12 +1,12 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/bc7c5a81f3d680de7b32b5671550e6fa/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/06a33bd735fe2a3eaad523052759de91/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/bc7c5a81f3d680de7b32b5671550e6fa/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/06a33bd735fe2a3eaad523052759de91/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/f257be9f04bfdf169051808541767806/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/1dcbacacca32618bd21ec5465779ade1/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/bc7c5a81f3d680de7b32b5671550e6fa/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/06a33bd735fe2a3eaad523052759de91/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/476d3c08f4989328dee56d22e202d98d/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/5a88b04b5e582b332d2e6bc12b45f1b9/redirect toolchainVersion=21 diff --git a/version.properties b/version.properties index 478eaea..65b5a9e 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.0 -VERSION_CODE=1 +VERSION_NAME=1.0.1 +VERSION_CODE=2