diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml new file mode 100644 index 00000000..6ab91bdd --- /dev/null +++ b/.github/workflows/generate-changelog.yml @@ -0,0 +1,123 @@ +name: Generate Release Changelog + +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: 'Tag to generate changelog for' + required: true + type: string + +jobs: + generate-changelog: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Previous Release Tag + id: get_previous_tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + CURRENT_TAG="${{ github.event.inputs.tag }}" + else + CURRENT_TAG="${{ github.event.release.tag_name }}" + fi + + PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -A1 "^${CURRENT_TAG}$" | tail -n1) + + if [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ]; then + # If no previous tag found, use first commit + PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD) + fi + + echo "current_tag=$CURRENT_TAG" >> $GITHUB_OUTPUT + echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + echo "Current: $CURRENT_TAG, Previous: $PREVIOUS_TAG" + + - name: Generate Changelog from Commits + id: generate_changelog + run: | + cat > changelog_section.md << 'EOF' + ## [${{ steps.get_previous_tag.outputs.current_tag }}] - $(date +%Y-%m-%d) + + ### Added + EOF + + # Get commits between tags and categorize them + git log ${{ steps.get_previous_tag.outputs.previous_tag }}..${{ steps.get_previous_tag.outputs.current_tag }} \ + --pretty=format:"- %s" \ + --grep="feat:" --grep="add:" | head -20 >> changelog_section.md + + echo "" >> changelog_section.md + echo "### Changed" >> changelog_section.md + + git log ${{ steps.get_previous_tag.outputs.previous_tag }}..${{ steps.get_previous_tag.outputs.current_tag }} \ + --pretty=format:"- %s" \ + --grep="update:" --grep="change:" --grep="improve:" | head -20 >> changelog_section.md + + echo "" >> changelog_section.md + echo "### Fixed" >> changelog_section.md + + git log ${{ steps.get_previous_tag.outputs.previous_tag }}..${{ steps.get_previous_tag.outputs.current_tag }} \ + --pretty=format:"- %s" \ + --grep="fix:" --grep="bug:" | head -20 >> changelog_section.md + + echo "" >> changelog_section.md + + - name: Update CHANGELOG.md + run: | + if [ ! -f CHANGELOG.md ]; then + echo "# Changelog" > CHANGELOG.md + echo "" >> CHANGELOG.md + echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md + echo "" >> CHANGELOG.md + fi + + # Create temp file with new entry + cp CHANGELOG.md CHANGELOG_TMP.md + + # Insert new changelog section after the header + awk ' + /^# Changelog/ { print; getline; print; print ""; while ((getline line < "changelog_section.md") > 0) print line; close("changelog_section.md"); print "" } + !/^# Changelog/ { print } + ' CHANGELOG.md > CHANGELOG_NEW.md + + mv CHANGELOG_NEW.md CHANGELOG.md + rm -f changelog_section.md + + - name: Check for Changes + id: check_changes + run: | + if git diff --quiet CHANGELOG.md; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Create Pull Request + if: steps.check_changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: "changelog/release-${{ steps.get_previous_tag.outputs.current_tag }}" + commit-message: "docs: update changelog for ${{ steps.get_previous_tag.outputs.current_tag }}" + title: "📜 Update Changelog for ${{ steps.get_previous_tag.outputs.current_tag }}" + body: | + **Description**: + This PR updates the CHANGELOG.md with release notes for ${{ steps.get_previous_tag.outputs.current_tag }}. + + **Changes**: + - Generated changelog from commits between ${{ steps.get_previous_tag.outputs.previous_tag }} and ${{ steps.get_previous_tag.outputs.current_tag }} + - Categorized changes into Added, Changed, and Fixed sections + + **Note**: Please review and edit the changelog as needed before merging. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..371d8881 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- **Variable Speed Playback**: Audio notes can now be played at 1.0x, 1.5x, or 2.0x speed for faster review +- Playback speed preferences are saved and restored between app sessions +- Visual speed indicator in the audio player + +## [1.1.4] - 2025-07-20 + +### Added +- Initial fork of NotelyVoice with personalisation focus +- Enhanced README with fork management and contribution guidelines +- Streamlined UI components and navigation + +### Changed +- Package structure optimization for Android focus +- Updated dependencies to latest stable versions + +### Fixed +- Minor UI inconsistencies in note list view diff --git a/README.md b/README.md index 4d322212..8d56b3c7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ Download the latest APK files directly from [GitHub Releases](https://github.com 🌐 **Offline Capability** - Speech recognition works without an internet connection 🔄 **Seamless Integration** - Dictate directly into notes or transcribe audio recordings 🎧 **Audio Recording** - Record voice notes and play them back within the app -🎧 **Unlimited Transcriptions** - Transcribe unlimited voice notes to multiple languages +🎧 **Unlimited Transcriptions** - Transcribe unlimited voice notes to multiple languages +⚡ **Variable Speed Playback** - Adjust audio playback speed (1x, 1.5x, 2x) for faster note review ### General 🌓 **Theming** - Switch between dark and light themes based on your preference diff --git a/backlog/tasks/task-002 - Implement-variable-speed-playback-for-audio-notes.md b/backlog/tasks/task-002 - Implement-variable-speed-playback-for-audio-notes.md index a757ac79..182fec75 100644 --- a/backlog/tasks/task-002 - Implement-variable-speed-playback-for-audio-notes.md +++ b/backlog/tasks/task-002 - Implement-variable-speed-playback-for-audio-notes.md @@ -1,9 +1,11 @@ --- id: task-002 title: Implement variable speed playback for audio notes -status: To Do -assignee: [] +status: In Progress +assignee: + - '@stanvx' created_date: '2025-07-19' +updated_date: '2025-07-19' labels: [] dependencies: [] --- @@ -14,9 +16,13 @@ Allow users to adjust playback speed (1x, 1.5x, 2x) to improve listening experie ## Acceptance Criteria -- [ ] PlatformAudioPlayer interface includes setPlaybackSpeed method -- [ ] Android implementation uses MediaPlayer.setPlaybackParams -- [ ] iOS implementation uses AVPlayer.rate -- [ ] UI provides speed selection controls (1.0x 1.5x 2.0x) -- [ ] Speed preference persists across app sessions -- [ ] Current speed is visually indicated to user +- [x] PlatformAudioPlayer interface includes setPlaybackSpeed method +- [x] Android implementation uses MediaPlayer.setPlaybackParams +- [x] iOS implementation uses AVPlayer.rate +- [x] UI provides speed selection controls (1.0x 1.5x 2.0x) +- [x] Speed preference persists across app sessions +- [x] Current speed is visually indicated to user + +## Implementation Plan + +1. Add setPlaybackSpeed method to PlatformAudioPlayer interface\n2. Implement Android playback speed using MediaPlayer.setPlaybackParams\n3. Implement iOS playback speed using AVPlayer.rate\n4. Add playback speed to UI and presentation state models\n5. Create speed toggle button UI component (1.0x → 1.5x → 2.0x → cycle)\n6. Add speed preference persistence using DataStore\n7. Update UI to display current speed and handle speed changes\n8. Test implementation on both platforms diff --git a/backlog/tasks/task-013 - Add-quick-record-shortcut-for-streamlined-audio-capture.md b/backlog/tasks/task-013 - Add-quick-record-shortcut-for-streamlined-audio-capture.md new file mode 100644 index 00000000..dd856636 --- /dev/null +++ b/backlog/tasks/task-013 - Add-quick-record-shortcut-for-streamlined-audio-capture.md @@ -0,0 +1,26 @@ +--- +id: task-013 +title: Add quick record shortcut for streamlined audio capture +status: To Do +assignee: [] +created_date: '2025-07-19' +updated_date: '2025-07-19' +labels: [] +dependencies: [] +priority: high +--- + +## Description + +Implement a direct recording flow that bypasses the current multi-step process (plus → microphone → record → stop → transcribe → append → back). Users should be able to start recording with one click, stop with another click, and have transcription/append happen automatically in the background. + +## Acceptance Criteria + +- [ ] Quick record button available on main screen +- [ ] Single click starts recording and navigates to recording screen +- [ ] Single click stops recording and returns to main screen +- [ ] Transcription happens automatically in background after recording stops +- [ ] New note is created and transcription is appended automatically +- [ ] No manual transcription or append button interaction required +- [ ] Reuses existing recording and transcription components +- [ ] Flow reduces current 7+ clicks to just 2 clicks diff --git a/backlog/tasks/task-014 - Add-support-for-16KB-page-sizes-on-Android.md b/backlog/tasks/task-014 - Add-support-for-16KB-page-sizes-on-Android.md new file mode 100644 index 00000000..4cafebe1 --- /dev/null +++ b/backlog/tasks/task-014 - Add-support-for-16KB-page-sizes-on-Android.md @@ -0,0 +1,23 @@ +--- +id: task-014 +title: Add support for 16KB page sizes on Android +status: To Do +assignee: [] +created_date: '2025-07-19' +labels: [] +dependencies: [] +--- + +## Description + +Implement support for Android devices with 16KB page sizes to ensure compatibility with newer Android devices and future-proof the application. This involves updating native code, build configurations, and testing on devices with different page sizes. + +## Acceptance Criteria + +- [ ] App builds and runs correctly on devices with 16KB page sizes +- [ ] Native audio processing (Whisper C++) works with 16KB pages +- [ ] SQLDelight database operations handle 16KB page sizes +- [ ] No crashes or performance degradation on 16KB page size devices +- [ ] Build configuration updated to support multiple page sizes +- [ ] Testing performed on both 4KB and 16KB page size devices +- [ ] Documentation updated with page size compatibility notes diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 8675954c..73063f3f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -98,13 +98,18 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + implementation(libs.koin.test) + implementation(libs.datastore.preferences) } } } targets.all { compilations.all { - kotlinOptions.freeCompilerArgs += "-Xexpect-actual-classes" + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + } } } diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/HapticFeedback.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/HapticFeedback.android.kt new file mode 100644 index 00000000..711e83b6 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/HapticFeedback.android.kt @@ -0,0 +1,94 @@ +package com.module.notelycompose.platform + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.HapticFeedbackConstants + +actual class HapticFeedback { + private var context: Context? = null + + fun initialize(context: Context) { + this.context = context + } + + actual fun light() { + performHapticFeedback(HapticFeedbackType.LIGHT) + } + + actual fun medium() { + performHapticFeedback(HapticFeedbackType.MEDIUM) + } + + actual fun heavy() { + performHapticFeedback(HapticFeedbackType.HEAVY) + } + + actual fun success() { + performHapticFeedback(HapticFeedbackType.SUCCESS) + } + + actual fun error() { + performHapticFeedback(HapticFeedbackType.ERROR) + } + + private fun performHapticFeedback(type: HapticFeedbackType) { + val context = this.context ?: return + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Android 12+ - Use VibratorManager + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager + val vibrator = vibratorManager?.defaultVibrator + vibrator?.let { performModernHapticFeedback(it, type) } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android 8.0+ - Use VibrationEffect + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + vibrator?.let { performModernHapticFeedback(it, type) } + } else { + // Legacy Android - Use simple vibration + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + vibrator?.let { performLegacyHapticFeedback(it, type) } + } + } catch (e: Exception) { + android.util.Log.w("HapticFeedback", "Failed to perform haptic feedback: ${e.message}") + } + } + + private fun performModernHapticFeedback(vibrator: Vibrator, type: HapticFeedbackType) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = when (type) { + HapticFeedbackType.LIGHT -> VibrationEffect.createOneShot(50, 50) + HapticFeedbackType.MEDIUM -> VibrationEffect.createOneShot(100, 100) + HapticFeedbackType.HEAVY -> VibrationEffect.createOneShot(150, 200) + HapticFeedbackType.SUCCESS -> VibrationEffect.createWaveform(longArrayOf(0, 50, 50, 100), -1) + HapticFeedbackType.ERROR -> VibrationEffect.createWaveform(longArrayOf(0, 100, 50, 100, 50, 100), -1) + } + + if (vibrator.hasVibrator()) { + vibrator.vibrate(effect) + } + } + } + + private fun performLegacyHapticFeedback(vibrator: Vibrator, type: HapticFeedbackType) { + if (vibrator.hasVibrator()) { + val duration = when (type) { + HapticFeedbackType.LIGHT -> 50L + HapticFeedbackType.MEDIUM -> 100L + HapticFeedbackType.HEAVY -> 150L + HapticFeedbackType.SUCCESS -> 200L + HapticFeedbackType.ERROR -> 300L + } + + @Suppress("DEPRECATION") + vibrator.vibrate(duration) + } + } + + private enum class HapticFeedbackType { + LIGHT, MEDIUM, HEAVY, SUCCESS, ERROR + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt index cefd8118..a643bac1 100644 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt @@ -1,5 +1,8 @@ package com.module.notelycompose.platform +import android.media.PlaybackParams +import android.os.Build + actual class PlatformAudioPlayer { private var mediaPlayer: android.media.MediaPlayer? = null @@ -46,4 +49,41 @@ actual class PlatformAudioPlayer { actual fun isPlaying(): Boolean { return mediaPlayer?.isPlaying ?: false } + + actual fun setPlaybackSpeed(speed: Float) { + mediaPlayer?.let { player -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + try { + // Validate speed range for Android + if (speed < 0.125f || speed > 8.0f) { + android.util.Log.w("PlatformAudioPlayer", + "Warning: Android playback speed $speed is outside supported range (0.125-8.0)") + } + + val params = PlaybackParams().setSpeed(speed) + player.playbackParams = params + android.util.Log.d("PlatformAudioPlayer", + "Successfully set Android playback speed to $speed") + } catch (e: IllegalStateException) { + android.util.Log.e("PlatformAudioPlayer", + "IllegalStateException setting playback speed to $speed: MediaPlayer in invalid state", e) + // MediaPlayer is in invalid state - gracefully ignore + } catch (e: IllegalArgumentException) { + android.util.Log.e("PlatformAudioPlayer", + "IllegalArgumentException setting playback speed to $speed: Invalid speed value", e) + // Invalid speed value - gracefully ignore + } catch (e: Exception) { + android.util.Log.e("PlatformAudioPlayer", + "Unexpected error setting playback speed to $speed", e) + // Fallback: ignore speed change if not supported + } + } else { + android.util.Log.w("PlatformAudioPlayer", + "Playback speed control not supported on API level ${Build.VERSION.SDK_INT} (requires API 23+)") + } + } ?: run { + android.util.Log.w("PlatformAudioPlayer", + "Cannot set playback speed - MediaPlayer is null") + } + } } \ No newline at end of file diff --git a/shared/src/androidTest/kotlin/com/module/notelycompose/platform/PlatformAudioPlayerAndroidTest.kt b/shared/src/androidTest/kotlin/com/module/notelycompose/platform/PlatformAudioPlayerAndroidTest.kt new file mode 100644 index 00000000..fde11823 --- /dev/null +++ b/shared/src/androidTest/kotlin/com/module/notelycompose/platform/PlatformAudioPlayerAndroidTest.kt @@ -0,0 +1,75 @@ +package com.module.notelycompose.platform + +import android.os.Build +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class PlatformAudioPlayerAndroidTest { + + @Test + fun `setPlaybackSpeed does not crash with valid speeds`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val validSpeeds = listOf(1.0f, 1.5f, 2.0f) + + // Should not crash even without prepared media + validSpeeds.forEach { speed -> + audioPlayer.setPlaybackSpeed(speed) + } + + assertTrue(true, "No crash occurred with valid speeds") + } + + @Test + fun `setPlaybackSpeed handles extreme values gracefully`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val extremeSpeeds = listOf(0.125f, 8.0f, 100.0f, Float.MAX_VALUE) + + // Should not crash even with extreme values + extremeSpeeds.forEach { speed -> + audioPlayer.setPlaybackSpeed(speed) + } + + assertTrue(true, "No crash occurred with extreme speeds") + } + + @Test + fun `playback speed is supported on API 23+`() { + val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + if (isSupported) { + assertTrue(true, "Playback speed should be supported on API ${Build.VERSION.SDK_INT}") + } else { + assertTrue(true, "Playback speed not supported on API ${Build.VERSION.SDK_INT}, but should handle gracefully") + } + } + + @Test + fun `audio player initializes correctly`() = runTest { + val audioPlayer = PlatformAudioPlayer() + + // Basic state checks + assertTrue(!audioPlayer.isPlaying(), "Should not be playing initially") + assertTrue(audioPlayer.getCurrentPosition() == 0, "Initial position should be 0") + } + + @Test + fun `prepare with non-existent file returns error duration`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val duration = audioPlayer.prepare("/non/existent/file.mp3") + + assertTrue(duration == 0, "Should return 0 duration for non-existent file") + } + + @Test + fun `release cleans up resources safely`() = runTest { + val audioPlayer = PlatformAudioPlayer() + + // Should not crash + audioPlayer.release() + audioPlayer.release() // Multiple calls should be safe + + assertTrue(true, "Release should handle multiple calls safely") + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt index f46f603a..1c503d33 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt @@ -2,8 +2,10 @@ package com.module.notelycompose.audio.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Stable import com.module.notelycompose.audio.presentation.mappers.AudioPlayerPresentationToUiMapper import com.module.notelycompose.audio.ui.player.model.AudioPlayerUiState +import com.module.notelycompose.onboarding.data.PreferencesRepository import com.module.notelycompose.platform.PlatformAudioPlayer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -12,8 +14,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Platform-independent ViewModel for audio playback functionality @@ -21,20 +26,66 @@ import kotlinx.coroutines.launch class AudioPlayerViewModel( private val audioPlayer: PlatformAudioPlayer, private val mapper: AudioPlayerPresentationToUiMapper, + private val preferencesRepository: PreferencesRepository, ):ViewModel(){ private var progressUpdateJob: Job? = null + private val speedUpdateMutex = Mutex() private val _uiState = MutableStateFlow(AudioPlayerPresentationState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + // Load saved playback speed + viewModelScope.launch { + try { + val savedSpeed = preferencesRepository.getPlaybackSpeed().first() + _uiState.update { it.copy(playbackSpeed = savedSpeed) } + audioPlayer.setPlaybackSpeed(savedSpeed) + } catch (e: Exception) { + // Use default speed if unable to load preferences + println("Failed to load playback speed: ${e.message}") + } + } + } + fun onGetUiState(presentationState: AudioPlayerPresentationState): AudioPlayerUiState { return mapper.mapToUiState(presentationState) } + fun onTogglePlaybackSpeed() { + viewModelScope.launch { + speedUpdateMutex.withLock { + try { + val currentSpeed = _uiState.value.playbackSpeed + val nextSpeed = when (currentSpeed) { + 1.0f -> 1.5f + 1.5f -> 2.0f + else -> 1.0f + } + + // Apply speed to audio player first + audioPlayer.setPlaybackSpeed(nextSpeed) + + // Update UI state + _uiState.update { it.copy(playbackSpeed = nextSpeed) } + + // Save to preferences (validation happens here) + preferencesRepository.setPlaybackSpeed(nextSpeed) + + } catch (e: Exception) { + println("Error setting playback speed: ${e.message}") + // On error, keep current state unchanged + } + } + } + } + fun onLoadAudio(filePath: String) { viewModelScope.launch(Dispatchers.Default) { try { val duration = audioPlayer.prepare(filePath) + val currentSpeed = _uiState.value.playbackSpeed + audioPlayer.setPlaybackSpeed(currentSpeed) // Apply current speed to new audio _uiState.update { it.copy( isLoaded = true, duration = duration, @@ -127,11 +178,13 @@ class AudioPlayerViewModel( /** * Data class representing the UI state of the audio player */ +@Stable data class AudioPlayerPresentationState( val isLoaded: Boolean = false, val isPlaying: Boolean = false, val currentPosition: Int = 0, val duration: Int = 0, val errorMessage: String? = null, - val filePath: String = "" + val filePath: String = "", + val playbackSpeed: Float = 1.0f ) diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/mappers/AudioPlayerPresentationToUiMapper.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/mappers/AudioPlayerPresentationToUiMapper.kt index cb7f4313..957cbe29 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/mappers/AudioPlayerPresentationToUiMapper.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/mappers/AudioPlayerPresentationToUiMapper.kt @@ -10,7 +10,8 @@ class AudioPlayerPresentationToUiMapper { isPlaying = presentationState.isPlaying, currentPosition = presentationState.currentPosition, duration = presentationState.duration, - errorMessage = presentationState.errorMessage.orEmpty() + errorMessage = presentationState.errorMessage.orEmpty(), + playbackSpeed = presentationState.playbackSpeed ) } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/PlatformAudioPlayerUi.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/PlatformAudioPlayerUi.kt index f343a87b..de7b93ca 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/PlatformAudioPlayerUi.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/PlatformAudioPlayerUi.kt @@ -1,17 +1,22 @@ package com.module.notelycompose.audio.ui.player import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,6 +34,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -54,7 +61,9 @@ fun PlatformAudioPlayerUi( onLoadAudio: (filePath: String) -> Unit, onClear: () -> Unit, onSeekTo: (position: Int) -> Unit, - onTogglePlayPause: () -> Unit + onTogglePlayPause: () -> Unit, + onTogglePlaybackSpeed: () -> Unit, + hapticFeedback: com.module.notelycompose.platform.HapticFeedback? = null ) { // Load audio when the composable is first created LaunchedEffect(filePath) { @@ -77,8 +86,19 @@ fun PlatformAudioPlayerUi( Box(modifier = Modifier.padding(horizontal = 4.dp)) { IconButton( - onClick = { onTogglePlayPause() }, - modifier = Modifier.size(28.dp), + onClick = { + hapticFeedback?.light() + onTogglePlayPause() + }, + modifier = Modifier + .size(28.dp) + .semantics { + contentDescription = if (uiState.isPlaying) { + "Pause audio playback" + } else { + "Play audio" + } + }, enabled = uiState.isLoaded ) { Icon( @@ -129,6 +149,41 @@ fun PlatformAudioPlayerUi( modifier = Modifier.padding(horizontal = 4.dp) ) } + + Box(modifier = Modifier.padding(horizontal = 4.dp)) { + Button( + onClick = { + hapticFeedback?.medium() + onTogglePlaybackSpeed() + }, + modifier = Modifier + .height(28.dp) + .widthIn(min = 48.dp) + .semantics { + contentDescription = "Change playback speed. Current speed: ${uiState.playbackSpeed}x. Tap to cycle between 1x, 1.5x, and 2x speeds." + }, + enabled = uiState.isLoaded, + colors = ButtonDefaults.buttonColors( + containerColor = LocalCustomColors.current.playerBoxBackgroundColor, + contentColor = if (uiState.isLoaded) Color.DarkGray else Color.LightGray, + disabledContainerColor = LocalCustomColors.current.playerBoxBackgroundColor, + disabledContentColor = Color.LightGray + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + border = BorderStroke( + width = 1.dp, + color = if (uiState.isLoaded) Color.DarkGray.copy(alpha = 0.3f) else Color.LightGray.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(4.dp) + ) { + val speedText = remember(uiState.playbackSpeed) { "${uiState.playbackSpeed}x" } + Text( + text = speedText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } } } } @@ -152,7 +207,9 @@ fun AudioSlider( onValueChange = { sliderPosition = it }, - modifier = modifier, + modifier = modifier.semantics { + contentDescription = "Audio progress slider. Current position: ${uiState.currentPosition.formatTimeToMMSS()} of ${uiState.duration.formatTimeToMMSS()}. Drag to seek to a different position." + }, thumb = { Thumb( interactionSource = interactionSource, diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/model/AudioPlayerUiState.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/model/AudioPlayerUiState.kt index ffec5422..c354984e 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/model/AudioPlayerUiState.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/model/AudioPlayerUiState.kt @@ -1,9 +1,13 @@ package com.module.notelycompose.audio.ui.player.model +import androidx.compose.runtime.Stable + +@Stable data class AudioPlayerUiState( val isLoaded: Boolean, val isPlaying: Boolean, val currentPosition: Int, val duration: Int, - val errorMessage: String + val errorMessage: String, + val playbackSpeed: Float ) diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/detail/NoteDetailScreen.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/detail/NoteDetailScreen.kt index e5d3fb76..dca9671d 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/detail/NoteDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/detail/NoteDetailScreen.kt @@ -394,7 +394,8 @@ private fun NoteContent( onLoadAudio = audioPlayerViewModel::onLoadAudio, onClear = audioPlayerViewModel::onClear, onSeekTo = audioPlayerViewModel::onSeekTo, - onTogglePlayPause = audioPlayerViewModel::onTogglePlayPause + onTogglePlayPause = audioPlayerViewModel::onTogglePlayPause, + onTogglePlaybackSpeed = audioPlayerViewModel::onTogglePlaybackSpeed ) } ) diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/onboarding/data/PreferencesRepository.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/onboarding/data/PreferencesRepository.kt index 5291e8c5..f5683830 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/onboarding/data/PreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/onboarding/data/PreferencesRepository.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.module.notelycompose.notes.ui.settings.languageCodeMap @@ -21,6 +22,11 @@ class PreferencesRepository( private val KEY_LANGUAGE = stringPreferencesKey("language") private val KEY_THEME = stringPreferencesKey("theme") private val KEY_MODEL_DOWNLOAD_ID= longPreferencesKey("model_download_id") + private val KEY_PLAYBACK_SPEED = floatPreferencesKey("playback_speed") + + // Playback speed validation constants + val VALID_PLAYBACK_SPEEDS = setOf(1.0f, 1.5f, 2.0f) + const val DEFAULT_PLAYBACK_SPEED = 1.0f } suspend fun hasCompletedOnboarding(): Boolean { @@ -63,5 +69,25 @@ class PreferencesRepository( prefs[KEY_MODEL_DOWNLOAD_ID] = downloadId } } + + fun getPlaybackSpeed(): Flow = dataStore.data.map { prefs -> + prefs[KEY_PLAYBACK_SPEED] ?: DEFAULT_PLAYBACK_SPEED + } + + suspend fun setPlaybackSpeed(speed: Float) { + // Validate speed value + when { + !speed.isFinite() -> throw IllegalArgumentException( + "Invalid playback speed: $speed. Speed must be a finite number." + ) + speed !in VALID_PLAYBACK_SPEEDS -> throw IllegalArgumentException( + "Invalid playback speed: $speed. Valid speeds: $VALID_PLAYBACK_SPEEDS" + ) + } + + dataStore.edit { prefs -> + prefs[KEY_PLAYBACK_SPEED] = speed + } + } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/platform/HapticFeedback.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/platform/HapticFeedback.kt new file mode 100644 index 00000000..328439d5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/platform/HapticFeedback.kt @@ -0,0 +1,31 @@ +package com.module.notelycompose.platform + +/** + * Platform-specific haptic feedback interface + */ +expect class HapticFeedback() { + /** + * Provides light haptic feedback for UI interactions + */ + fun light() + + /** + * Provides medium haptic feedback for confirmations + */ + fun medium() + + /** + * Provides strong haptic feedback for important actions + */ + fun heavy() + + /** + * Provides success haptic feedback + */ + fun success() + + /** + * Provides error haptic feedback + */ + fun error() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.kt index efa8e5ec..0c446b3d 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.kt @@ -9,4 +9,5 @@ expect class PlatformAudioPlayer() { fun seekTo(position: Int) fun getCurrentPosition(): Int fun isPlaying(): Boolean + fun setPlaybackSpeed(speed: Float) } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/audio/presentation/PlaybackSpeedValidationTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/audio/presentation/PlaybackSpeedValidationTest.kt new file mode 100644 index 00000000..0d84f410 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/audio/presentation/PlaybackSpeedValidationTest.kt @@ -0,0 +1,110 @@ +package com.module.notelycompose.audio.presentation + +import com.module.notelycompose.onboarding.data.PreferencesRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Tests for playback speed validation and constants + */ +class PlaybackSpeedValidationTest { + + @Test + fun `valid playback speeds are accepted`() { + val validSpeeds = PreferencesRepository.VALID_PLAYBACK_SPEEDS + + assertEquals(setOf(1.0f, 1.5f, 2.0f), validSpeeds) + assertTrue(validSpeeds.contains(PreferencesRepository.DEFAULT_PLAYBACK_SPEED)) + } + + @Test + fun `default playback speed is valid`() { + val defaultSpeed = PreferencesRepository.DEFAULT_PLAYBACK_SPEED + val validSpeeds = PreferencesRepository.VALID_PLAYBACK_SPEEDS + + assertEquals(1.0f, defaultSpeed) + assertTrue(validSpeeds.contains(defaultSpeed)) + } + + @Test + fun `speed cycling follows correct pattern`() { + // Test the speed cycling logic that would be in the ViewModel + fun getNextSpeed(currentSpeed: Float): Float { + return when (currentSpeed) { + 1.0f -> 1.5f + 1.5f -> 2.0f + else -> 1.0f + } + } + + // Test the complete cycle + assertEquals(1.5f, getNextSpeed(1.0f)) + assertEquals(2.0f, getNextSpeed(1.5f)) + assertEquals(1.0f, getNextSpeed(2.0f)) + + // Test edge cases + assertEquals(1.0f, getNextSpeed(3.0f)) // Invalid speed -> reset to 1.0f + assertEquals(1.0f, getNextSpeed(0.5f)) // Invalid speed -> reset to 1.0f + } + + @Test + fun `playback speed constants are properly defined`() { + // Ensure our constants match the expected values from the PR review + assertTrue(PreferencesRepository.VALID_PLAYBACK_SPEEDS.size == 3) + assertTrue(1.0f in PreferencesRepository.VALID_PLAYBACK_SPEEDS) + assertTrue(1.5f in PreferencesRepository.VALID_PLAYBACK_SPEEDS) + assertTrue(2.0f in PreferencesRepository.VALID_PLAYBACK_SPEEDS) + } + + @Test + fun `invalid speed detection works correctly`() { + val validSpeeds = PreferencesRepository.VALID_PLAYBACK_SPEEDS + + // Test invalid speeds + val invalidSpeeds = listOf(0.5f, 0.8f, 2.5f, 3.0f, -1.0f, 0.0f) + + for (speed in invalidSpeeds) { + assertTrue(!validSpeeds.contains(speed), "Speed $speed should not be valid") + } + } + + @Test + fun `finite number validation logic`() { + // Test the logic that would validate finite numbers + fun isValidFiniteSpeed(speed: Float): Boolean { + return speed.isFinite() && speed in PreferencesRepository.VALID_PLAYBACK_SPEEDS + } + + // Valid finite speeds + assertTrue(isValidFiniteSpeed(1.0f)) + assertTrue(isValidFiniteSpeed(1.5f)) + assertTrue(isValidFiniteSpeed(2.0f)) + + // Invalid finite speeds + assertTrue(!isValidFiniteSpeed(0.5f)) + assertTrue(!isValidFiniteSpeed(3.0f)) + + // Non-finite speeds + assertTrue(!isValidFiniteSpeed(Float.NaN)) + assertTrue(!isValidFiniteSpeed(Float.POSITIVE_INFINITY)) + assertTrue(!isValidFiniteSpeed(Float.NEGATIVE_INFINITY)) + } + + @Test + fun `playback speed state management`() { + // Test state management logic + data class PlaybackState(val speed: Float = PreferencesRepository.DEFAULT_PLAYBACK_SPEED) + + val initialState = PlaybackState() + assertEquals(1.0f, initialState.speed) + + val updatedState = initialState.copy(speed = 1.5f) + assertEquals(1.5f, updatedState.speed) + + // State should remain immutable + assertEquals(1.0f, initialState.speed) + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/platform/HapticFeedback.ios.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/platform/HapticFeedback.ios.kt new file mode 100644 index 00000000..e524a734 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/module/notelycompose/platform/HapticFeedback.ios.kt @@ -0,0 +1,64 @@ +package com.module.notelycompose.platform + +import com.module.notelycompose.core.debugPrintln +import platform.UIKit.UIImpactFeedbackGenerator +import platform.UIKit.UIImpactFeedbackStyle +import platform.UIKit.UINotificationFeedbackGenerator +import platform.UIKit.UINotificationFeedbackType +import platform.UIKit.UISelectionFeedbackGenerator + +actual class HapticFeedback { + + actual fun light() { + performImpactFeedback(UIImpactFeedbackStyle.UIImpactFeedbackStyleLight) + } + + actual fun medium() { + performImpactFeedback(UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium) + } + + actual fun heavy() { + performImpactFeedback(UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy) + } + + actual fun success() { + performNotificationFeedback(UINotificationFeedbackType.UINotificationFeedbackTypeSuccess) + } + + actual fun error() { + performNotificationFeedback(UINotificationFeedbackType.UINotificationFeedbackTypeError) + } + + private fun performImpactFeedback(style: UIImpactFeedbackStyle) { + try { + val impactGenerator = UIImpactFeedbackGenerator(style) + impactGenerator.prepare() + impactGenerator.impactOccurred() + debugPrintln{"iOS haptic feedback: impact style $style"} + } catch (e: Exception) { + debugPrintln{"Failed to perform iOS impact haptic feedback: ${e.message}"} + } + } + + private fun performNotificationFeedback(type: UINotificationFeedbackType) { + try { + val notificationGenerator = UINotificationFeedbackGenerator() + notificationGenerator.prepare() + notificationGenerator.notificationOccurred(type) + debugPrintln{"iOS haptic feedback: notification type $type"} + } catch (e: Exception) { + debugPrintln{"Failed to perform iOS notification haptic feedback: ${e.message}"} + } + } + + fun performSelectionFeedback() { + try { + val selectionGenerator = UISelectionFeedbackGenerator() + selectionGenerator.prepare() + selectionGenerator.selectionChanged() + debugPrintln{"iOS haptic feedback: selection changed"} + } catch (e: Exception) { + debugPrintln{"Failed to perform iOS selection haptic feedback: ${e.message}"} + } + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.ios.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.ios.kt index d5b710ca..f180cc49 100644 --- a/shared/src/iosMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.ios.kt +++ b/shared/src/iosMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.ios.kt @@ -87,4 +87,22 @@ actual class PlatformAudioPlayer actual constructor() { actual fun isPlaying(): Boolean { return audioPlayer?.isPlaying() ?: false } + + actual fun setPlaybackSpeed(speed: Float) { + try { + audioPlayer?.let { player -> + if (speed < 0.5f || speed > 2.0f) { + debugPrintln{"Warning: iOS playback speed $speed is outside recommended range (0.5-2.0)"} + } + player.rate = speed + debugPrintln{"Successfully set iOS playback speed to $speed"} + } ?: run { + debugPrintln{"Warning: Cannot set playback speed - audio player is null"} + } + } catch (e: Exception) { + debugPrintln{"Error setting iOS playback speed to $speed: ${e.message}"} + // Gracefully handle the error - don't crash the app + // iOS will continue playing at current speed + } + } } diff --git a/shared/src/iosTest/kotlin/com/module/notelycompose/platform/PlatformAudioPlayerIosTest.kt b/shared/src/iosTest/kotlin/com/module/notelycompose/platform/PlatformAudioPlayerIosTest.kt new file mode 100644 index 00000000..9d57a2b3 --- /dev/null +++ b/shared/src/iosTest/kotlin/com/module/notelycompose/platform/PlatformAudioPlayerIosTest.kt @@ -0,0 +1,85 @@ +package com.module.notelycompose.platform + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class PlatformAudioPlayerIosTest { + + @Test + fun `setPlaybackSpeed does not crash with valid speeds`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val validSpeeds = listOf(1.0f, 1.5f, 2.0f) + + // Should not crash even without prepared media + validSpeeds.forEach { speed -> + audioPlayer.setPlaybackSpeed(speed) + } + + assertTrue(true, "No crash occurred with valid speeds") + } + + @Test + fun `setPlaybackSpeed handles iOS edge cases`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val iosSpeeds = listOf(0.5f, 2.0f, 0.25f, 4.0f) + + // iOS supports wider range than our app uses + iosSpeeds.forEach { speed -> + audioPlayer.setPlaybackSpeed(speed) + } + + assertTrue(true, "No crash occurred with iOS-specific speeds") + } + + @Test + fun `audio player initializes correctly on iOS`() = runTest { + val audioPlayer = PlatformAudioPlayer() + + // Basic state checks + assertTrue(!audioPlayer.isPlaying(), "Should not be playing initially") + assertTrue(audioPlayer.getCurrentPosition() == 0, "Initial position should be 0") + } + + @Test + fun `prepare with non-existent file returns error duration on iOS`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val duration = audioPlayer.prepare("/non/existent/file.mp3") + + assertTrue(duration == 0, "Should return 0 duration for non-existent file") + } + + @Test + fun `release cleans up iOS audio resources safely`() = runTest { + val audioPlayer = PlatformAudioPlayer() + + // Should not crash + audioPlayer.release() + audioPlayer.release() // Multiple calls should be safe + + assertTrue(true, "Release should handle multiple calls safely on iOS") + } + + @Test + fun `iOS rate property accepts floating point values`() = runTest { + val audioPlayer = PlatformAudioPlayer() + val preciseRates = listOf(1.25f, 1.75f, 0.875f) + + // iOS AVAudioPlayer supports precise rate values + preciseRates.forEach { rate -> + audioPlayer.setPlaybackSpeed(rate) + } + + assertTrue(true, "iOS should handle precise rate values") + } + + @Test + fun `iOS playback speed with null audio player handles gracefully`() = runTest { + val audioPlayer = PlatformAudioPlayer() + + // Before any audio is loaded, audioPlayer should be null internally + audioPlayer.setPlaybackSpeed(1.5f) + + assertTrue(true, "Should handle null audioPlayer gracefully") + } +} \ No newline at end of file