Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 62 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| :-: | :-: | :-: | :-: |
| <img src="docs/screenshots/01%20-%20app%20installed.png" width="200" alt="App Installed" /> | <img src="docs/screenshots/02%20-%20select%20root%20folder.png" width="200" alt="Select Root Folder" /> | <img src="docs/screenshots/03%20-%20root%20folder%20access.png" width="200" alt="Root Access Granted" /> | <img src="docs/screenshots/04%20-%20app%20permissions.png" width="200" alt="Media Permission" /> |

### Playlist Management & Minting
| Empty Playlist History | Mint Shuffle Mode | Active Playlist History |
| :-: | :-: | :-: |
| <img src="docs/screenshots/05%20-%20no%20playlists%20loaded.png" width="220" alt="No Playlists Loaded" /> | <img src="docs/screenshots/06%20-%20select%20shuffle%20mode.png" width="220" alt="Select Shuffle Mode" /> | <img src="docs/screenshots/07%20-%20one%20of%20each%20mode%20loaded.png" width="220" alt="Loaded Playlists" /> |

### Playback, Widget & Configuration
| Minimalist Now Playing | Home Screen Widget | Settings & Diagnostics |
| :-: | :-: | :-: |
| <img src="docs/screenshots/08%20-%20now%20playing.png" width="220" alt="Now Playing" /> | <img src="docs/screenshots/09%20-%20widget.png" width="220" alt="Home Widget" /> | <img src="docs/screenshots/10%20-%20configuration.png" width="220" alt="Configuration" /> |

## πŸ“ Storage & Scoped Storage Compliance

Expand All @@ -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`
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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!

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ android {

buildFeatures {
compose = true
buildConfig = true
}

@Suppress("UnstableApiUsage")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -42,8 +41,8 @@ class PlaylistNavigationTest {
)

val pickerViewModel = PlaylistPickerViewModel(
ApplicationProvider.getApplicationContext<Application>(),
playlistStateDao
ApplicationProvider.getApplicationContext(),
playlistStateDao,
)

// We use a state to control navigation in the test
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Application>(),
ApplicationProvider.getApplicationContext(),
playlistStateDao
)
}
Expand All @@ -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()

Expand All @@ -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()
Expand All @@ -81,7 +94,10 @@ class PlaylistPickerScreenTest {
fun testErrorSnackbarVisibility() {
composeTestRule.setContent {
CygnusPlayerTheme {
PlaylistPickerScreen(viewModel = viewModel, onPlaylistSelected = {})
PlaylistPickerScreen(
viewModel = viewModel,
onPlaylistSelected = {}
) {}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading