├── LICENSE ├── README.md ├── android ├── .gitignore ├── .idea │ ├── .gitignore │ ├── .name │ ├── codeStyles │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── inspectionProfiles │ │ └── Project_Default.xml │ └── misc.xml ├── app │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── fabirt │ │ │ └── podcastapp │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── ic_launcher-playstore.png │ │ ├── java │ │ │ └── com │ │ │ │ └── fabirt │ │ │ │ └── podcastapp │ │ │ │ ├── application │ │ │ │ └── PodcastApplication.kt │ │ │ │ ├── constant │ │ │ │ └── K.kt │ │ │ │ ├── data │ │ │ │ ├── datastore │ │ │ │ │ └── PodcastDataStore.kt │ │ │ │ ├── exoplayer │ │ │ │ │ ├── MediaPlaybackPreparer.kt │ │ │ │ │ ├── MediaPlayerNotificationListener.kt │ │ │ │ │ ├── MediaPlayerNotificationManager.kt │ │ │ │ │ ├── MediaPlayerQueueNavigator.kt │ │ │ │ │ └── PodcastMediaSource.kt │ │ │ │ ├── network │ │ │ │ │ ├── client │ │ │ │ │ │ └── ListenNotesAPIClient.kt │ │ │ │ │ ├── constant │ │ │ │ │ │ └── ListenNotesAPI.kt │ │ │ │ │ ├── model │ │ │ │ │ │ ├── EpisodeDto.kt │ │ │ │ │ │ ├── PodcastDto.kt │ │ │ │ │ │ └── PodcastSearchDto.kt │ │ │ │ │ └── service │ │ │ │ │ │ └── PodcastService.kt │ │ │ │ └── service │ │ │ │ │ ├── MediaPlayerService.kt │ │ │ │ │ └── MediaPlayerServiceConnection.kt │ │ │ │ ├── di │ │ │ │ ├── AppModule.kt │ │ │ │ └── ServiceModule.kt │ │ │ │ ├── domain │ │ │ │ ├── model │ │ │ │ │ ├── Episode.kt │ │ │ │ │ ├── Podcast.kt │ │ │ │ │ └── PodcastSearch.kt │ │ │ │ └── repository │ │ │ │ │ ├── PodcastRepository.kt │ │ │ │ │ ├── PodcastRepositoryImpl.kt │ │ │ │ │ └── PodcastRepositoryMockImpl.kt │ │ │ │ ├── error │ │ │ │ └── Failure.kt │ │ │ │ ├── ui │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── common │ │ │ │ │ ├── BackButton.kt │ │ │ │ │ ├── EmphasisText.kt │ │ │ │ │ ├── IconButton.kt │ │ │ │ │ ├── PreviewContent.kt │ │ │ │ │ ├── PrimaryButton.kt │ │ │ │ │ ├── StaggeredVerticalGrid.kt │ │ │ │ │ └── ViewModelProvider.kt │ │ │ │ ├── home │ │ │ │ │ ├── ErrorView.kt │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ ├── LargeTitle.kt │ │ │ │ │ ├── LoadingPlaceholder.kt │ │ │ │ │ └── PodcastView.kt │ │ │ │ ├── navigation │ │ │ │ │ ├── Destination.kt │ │ │ │ │ └── Navigation.kt │ │ │ │ ├── podcast │ │ │ │ │ ├── PodcastBottomBar.kt │ │ │ │ │ ├── PodcastDetailScreen.kt │ │ │ │ │ ├── PodcastImage.kt │ │ │ │ │ └── PodcastPlayerScreen.kt │ │ │ │ ├── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── Theme.kt │ │ │ │ │ └── Type.kt │ │ │ │ ├── viewmodel │ │ │ │ │ ├── PodcastDetailViewModel.kt │ │ │ │ │ ├── PodcastPlayerViewModel.kt │ │ │ │ │ └── PodcastSearchViewModel.kt │ │ │ │ └── welcome │ │ │ │ │ ├── AnimatedButton.kt │ │ │ │ │ ├── AnimatedImage.kt │ │ │ │ │ ├── AnimatedTitle.kt │ │ │ │ │ └── WelcomeScreen.kt │ │ │ │ └── util │ │ │ │ ├── Date.kt │ │ │ │ ├── Either.kt │ │ │ │ ├── Locale.kt │ │ │ │ ├── Number.kt │ │ │ │ ├── PlaybackStateCompatExt.kt │ │ │ │ └── Resource.kt │ │ └── res │ │ │ ├── drawable │ │ │ ├── gradient_launcher.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_microphone.xml │ │ │ ├── ic_music_file.xml │ │ │ ├── ic_podcast.xml │ │ │ ├── ic_round_forward_10.xml │ │ │ ├── ic_round_pause.xml │ │ │ ├── ic_round_play_arrow.xml │ │ │ └── ic_round_replay_10.xml │ │ │ ├── font │ │ │ ├── nunito_sans_bold.ttf │ │ │ ├── nunito_sans_light.ttf │ │ │ ├── nunito_sans_regular.ttf │ │ │ └── nunito_sans_semibold.ttf │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ └── themes.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ └── test │ │ └── java │ │ └── com │ │ └── fabirt │ │ └── podcastapp │ │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle └── demo ├── detail_dark.png ├── detail_light.png ├── home_dark.png ├── home_light.png ├── listen-notes-demo.gif ├── notification_dark.png ├── notification_light.png ├── player_dark.png ├── player_light.png ├── welcome_dark.png └── welcome_light.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Fabian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podcast App 2 | 3 | Android podcast app made with Jetpack Compose and ExoPlayer. 4 | 5 | Podcast information provided by [Listen Notes API](https://www.listennotes.com/). 6 | 7 | ## Features 8 | 9 | - Jetpack Compose UI. Custom animations, transitions, light/dark theme, and layouts. 10 | - Jetpack Compose Navigation. 11 | - Dependency injection with Hilt. 12 | - MVVM Architecture. 13 | - Retrieves podcasts metadata from the network. 14 | - Allows background playback using a foreground service. 15 | - Media style notifications. 16 | - Uses a `MediaBrowserService` to control and expose the current media session. 17 | - Controls the current playback state with actions such as: play/pause, skip to next/previous, shuffle, repeat and stop. 18 | - Supports offline playback using `CacheDataSource` from `ExoPlayer`. 19 | - Process images to find its color palette using Palette API. 20 | 21 | ## Libraries 22 | 23 | - Jetpack Compose 24 | - ExoPlayer 25 | - Glide 26 | - Hilt 27 | - Retrofit 28 | - Navigation 29 | - ViewModel 30 | - DataStore 31 | - Palette API 32 | 33 | ## Result 34 | 35 | ### Dark Mode 36 | | ![welcome](demo/welcome_dark.png) | ![podcasts](demo/home_dark.png) |![detail](demo/detail_dark.png) |![player](demo/player_dark.png) | 37 | |----------|:-------------:|:-------------:|:-------------:| 38 | 39 | ### Light Mode 40 | | ![welcome](demo/welcome_light.png) | ![podcasts](demo/home_light.png) |![detail](demo/detail_light.png) |![player](demo/player_light.png) | 41 | |----------|:-------------:|:-------------:|:-------------:| 42 | 43 | ### Demo 44 | ![player](demo/listen-notes-demo.gif) -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | *.jks 56 | *.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | .cxx/ 61 | 62 | # Google Services (e.g. APIs or Firebase) 63 | # google-services.json 64 | 65 | # Freeline 66 | freeline.py 67 | freeline/ 68 | freeline_project_description.json 69 | 70 | # fastlane 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output 75 | fastlane/readme.md 76 | 77 | # Version control 78 | vcs.xml 79 | 80 | # lint 81 | lint/intermediates/ 82 | lint/generated/ 83 | lint/outputs/ 84 | lint/tmp/ 85 | # lint/reports/ 86 | 87 | # Android Profiling 88 | *.hprof 89 | .DS_Store 90 | 91 | -------------------------------------------------------------------------------- /android/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /android/.idea/.name: -------------------------------------------------------------------------------- 1 | PodcastApp -------------------------------------------------------------------------------- /android/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /android/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /android/.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 25 | -------------------------------------------------------------------------------- /android/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 54 | 55 | 57 | -------------------------------------------------------------------------------- /android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'dagger.hilt.android.plugin' 6 | } 7 | 8 | def localProperties = new Properties() 9 | localProperties.load(new FileInputStream(rootProject.file("local.properties"))) 10 | 11 | android { 12 | compileSdk 30 13 | buildToolsVersion "31.0.0" 14 | 15 | defaultConfig { 16 | applicationId "com.fabirt.podcastapp" 17 | minSdk 26 18 | targetSdk 30 19 | versionCode 2 20 | versionName "1.0.1" 21 | 22 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 23 | vectorDrawables { 24 | useSupportLibrary true 25 | } 26 | 27 | buildConfigField "String", "API_KEY", "\"" + localProperties['apiKey'] + "\"" 28 | } 29 | 30 | buildTypes { 31 | release { 32 | minifyEnabled false 33 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 34 | } 35 | } 36 | compileOptions { 37 | sourceCompatibility JavaVersion.VERSION_1_8 38 | targetCompatibility JavaVersion.VERSION_1_8 39 | } 40 | kotlinOptions { 41 | jvmTarget = '1.8' 42 | useIR = true 43 | } 44 | buildFeatures { 45 | compose true 46 | } 47 | composeOptions { 48 | kotlinCompilerExtensionVersion compose_version 49 | kotlinCompilerVersion '1.4.32' 50 | } 51 | } 52 | 53 | dependencies { 54 | 55 | implementation 'androidx.core:core-ktx:1.5.0' 56 | implementation 'androidx.appcompat:appcompat:1.3.0' 57 | implementation 'com.google.android.material:material:1.3.0' 58 | implementation "androidx.compose.ui:ui:$compose_version" 59 | implementation "androidx.compose.material:material:$compose_version" 60 | implementation "androidx.compose.ui:ui-tooling:$compose_version" 61 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-alpha02' 62 | implementation 'androidx.activity:activity-compose:1.3.0-beta02' 63 | implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07" 64 | testImplementation 'junit:junit:4.13.2' 65 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 66 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 67 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 68 | 69 | // Kotlin 70 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 71 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version" 72 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" 73 | 74 | 75 | // Navigation 76 | implementation "androidx.navigation:navigation-compose:2.4.0-alpha03" 77 | 78 | // Compose Accompanist 79 | implementation "com.google.accompanist:accompanist-insets:$accompanist_version" 80 | implementation "com.google.accompanist:accompanist-coil:$accompanist_version" 81 | 82 | // Hilt - dependency injection 83 | implementation "com.google.dagger:hilt-android:$hilt_version" 84 | kapt "com.google.dagger:hilt-compiler:$hilt_version" 85 | implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03' 86 | kapt 'androidx.hilt:hilt-compiler:1.0.0' 87 | 88 | // Retrofit 89 | implementation "com.squareup.retrofit2:retrofit:$retrofit_version" 90 | implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" 91 | 92 | // Preferences DataStore 93 | implementation "androidx.datastore:datastore-preferences:1.0.0-beta02" 94 | 95 | // ExoPlayer 96 | implementation "com.google.android.exoplayer:exoplayer:$exo_player_version" 97 | implementation "com.google.android.exoplayer:extension-mediasession:$exo_player_version" 98 | 99 | // Glide image loading 100 | implementation "com.github.bumptech.glide:glide:$glide_version" 101 | 102 | // Palette API - Selecting colors 103 | // implementation 'com.android.support:palette-v7:28.0.0' 104 | implementation 'androidx.palette:palette-ktx:1.0.0' 105 | } -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /android/app/src/androidTest/java/com/fabirt/podcastapp/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.fabirt.podcastapp", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 17 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /android/app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/android/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/application/PodcastApplication.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.application 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class PodcastApplication: Application() -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/constant/K.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.constant 2 | 3 | object K { 4 | 5 | const val PLAYBACK_NOTIFICATION_CHANNEL_ID = "UEBWUJBQ0tfTk9USUZJQ0FUSU9OX0NIQU5ORUxfSUQ" 6 | 7 | const val PLAYBACK_NOTIFICATION_ID = 115234045 8 | 9 | const val MEDIA_ROOT_ID = "TUVESUFfUk9PVF9JRA" 10 | 11 | const val START_MEDIA_PLAYBACK_ACTION = "START_MEDIA_PLAYBACK_ACTION" 12 | 13 | const val REFRESH_MEDIA_BROWSER_CHILDREN = "REFRESH_MEDIA_BROWSER_CHILDREN" 14 | 15 | const val PLAYBACK_POSITION_UPDATE_INTERVAL = 1000L 16 | 17 | const val ACTION_PODCAST_NOTIFICATION_CLICK = "ACTION_PODCAST_NOTIFICATION_CLICK" 18 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/datastore/PodcastDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.datastore 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.preferences.core.Preferences 7 | import androidx.datastore.preferences.core.edit 8 | import androidx.datastore.preferences.core.longPreferencesKey 9 | import androidx.datastore.preferences.core.stringPreferencesKey 10 | import androidx.datastore.preferences.preferencesDataStore 11 | import com.fabirt.podcastapp.domain.model.PodcastSearch 12 | import com.google.gson.Gson 13 | import kotlinx.coroutines.flow.first 14 | import kotlinx.coroutines.flow.map 15 | import java.time.Instant 16 | 17 | class PodcastDataStore( 18 | private val context: Context 19 | ) { 20 | private val lastAPIFetchMillis = longPreferencesKey("last_api_fetch_millis") 21 | private val podcastSearchResult = stringPreferencesKey("podcast_search_result") 22 | 23 | companion object { 24 | private const val TAG = "PodcastDataStore" 25 | } 26 | 27 | suspend fun storePodcastSearchResult(data: PodcastSearch) { 28 | context.podcastDataStore.edit { preferences -> 29 | val jsonString = Gson().toJson(data) 30 | Log.i(TAG, jsonString) 31 | preferences[lastAPIFetchMillis] = Instant.now().toEpochMilli() 32 | preferences[podcastSearchResult] = jsonString 33 | } 34 | } 35 | 36 | suspend fun readLastPodcastSearchResult(): PodcastSearch { 37 | return context.podcastDataStore.data.map { preferences -> 38 | val jsonString = preferences[podcastSearchResult] 39 | Gson().fromJson(jsonString, PodcastSearch::class.java) 40 | }.first() 41 | } 42 | 43 | suspend fun canFetchAPI(): Boolean { 44 | return context.podcastDataStore.data.map { preferences -> 45 | val epochMillis = preferences[lastAPIFetchMillis] 46 | 47 | return@map if (epochMillis != null) { 48 | val minDiffMillis = 36 * 60 * 60 * 1000L 49 | val now = Instant.now().toEpochMilli() 50 | (now - minDiffMillis) > epochMillis 51 | } else { 52 | true 53 | } 54 | }.first() 55 | } 56 | } 57 | 58 | private val Context.podcastDataStore: DataStore by preferencesDataStore(name = "podcasts") 59 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/exoplayer/MediaPlaybackPreparer.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.exoplayer 2 | 3 | import android.net.Uri 4 | import android.os.Bundle 5 | import android.os.ResultReceiver 6 | import android.support.v4.media.MediaMetadataCompat 7 | import android.support.v4.media.session.PlaybackStateCompat 8 | import com.google.android.exoplayer2.ControlDispatcher 9 | import com.google.android.exoplayer2.Player 10 | import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector 11 | 12 | class MediaPlaybackPreparer( 13 | private val mediaSource: PodcastMediaSource, 14 | private val playerPrepared: (MediaMetadataCompat?) -> Unit 15 | ) : MediaSessionConnector.PlaybackPreparer { 16 | override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) = Unit 17 | 18 | override fun onCommand( 19 | player: Player, 20 | controlDispatcher: ControlDispatcher, 21 | command: String, 22 | extras: Bundle?, 23 | cb: ResultReceiver? 24 | ): Boolean = false 25 | 26 | override fun getSupportedPrepareActions(): Long { 27 | return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID 28 | } 29 | 30 | override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { 31 | mediaSource.whenReady { 32 | val itemToPlay = mediaSource.mediaMetadataEpisodes.find { 33 | it.description.mediaId == mediaId 34 | } 35 | playerPrepared(itemToPlay) 36 | } 37 | } 38 | 39 | override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit 40 | 41 | override fun onPrepare(playWhenReady: Boolean) = Unit 42 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/exoplayer/MediaPlayerNotificationListener.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.exoplayer 2 | 3 | import android.app.Notification 4 | import android.content.Intent 5 | import androidx.core.content.ContextCompat 6 | import com.fabirt.podcastapp.constant.K 7 | import com.fabirt.podcastapp.data.service.MediaPlayerService 8 | import com.google.android.exoplayer2.ui.PlayerNotificationManager 9 | 10 | class MediaPlayerNotificationListener( 11 | private val mediaService: MediaPlayerService 12 | ) : PlayerNotificationManager.NotificationListener { 13 | 14 | override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { 15 | super.onNotificationCancelled(notificationId, dismissedByUser) 16 | mediaService.apply { 17 | stopForeground(true) 18 | isForegroundService = false 19 | stopSelf() 20 | } 21 | } 22 | 23 | override fun onNotificationPosted( 24 | notificationId: Int, 25 | notification: Notification, 26 | ongoing: Boolean 27 | ) { 28 | super.onNotificationPosted(notificationId, notification, ongoing) 29 | mediaService.apply { 30 | if (ongoing || !isForegroundService) { 31 | ContextCompat.startForegroundService( 32 | this, 33 | Intent(applicationContext, this::class.java) 34 | ) 35 | startForeground(K.PLAYBACK_NOTIFICATION_ID, notification) 36 | isForegroundService = true 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/exoplayer/MediaPlayerNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.exoplayer 2 | 3 | import android.app.PendingIntent 4 | import android.content.Context 5 | import android.graphics.Bitmap 6 | import android.graphics.drawable.Drawable 7 | import android.support.v4.media.session.MediaControllerCompat 8 | import android.support.v4.media.session.MediaSessionCompat 9 | import com.bumptech.glide.Glide 10 | import com.bumptech.glide.load.engine.DiskCacheStrategy 11 | import com.bumptech.glide.request.target.CustomTarget 12 | import com.bumptech.glide.request.transition.Transition 13 | import com.fabirt.podcastapp.R 14 | import com.fabirt.podcastapp.constant.K 15 | import com.google.android.exoplayer2.DefaultControlDispatcher 16 | import com.google.android.exoplayer2.Player 17 | import com.google.android.exoplayer2.ui.PlayerNotificationManager 18 | 19 | class MediaPlayerNotificationManager( 20 | private val context: Context, 21 | sessionToken: MediaSessionCompat.Token, 22 | notificationListener: PlayerNotificationManager.NotificationListener, 23 | private val newSongCallback: () -> Unit 24 | ) { 25 | 26 | private val notificationManager: PlayerNotificationManager 27 | 28 | init { 29 | val mediaController = MediaControllerCompat(context, sessionToken) 30 | notificationManager = 31 | createNotificationManger(mediaController, sessionToken, notificationListener) 32 | } 33 | 34 | fun showNotification(player: Player) { 35 | notificationManager.setPlayer(player) 36 | } 37 | 38 | private fun createNotificationManger( 39 | mediaController: MediaControllerCompat, 40 | sessionToken: MediaSessionCompat.Token, 41 | notificationListener: PlayerNotificationManager.NotificationListener 42 | ): PlayerNotificationManager { 43 | return PlayerNotificationManager.createWithNotificationChannel( 44 | context, 45 | K.PLAYBACK_NOTIFICATION_CHANNEL_ID, 46 | R.string.playback_notification_channel_name, 47 | R.string.playback_notification_channel_description, 48 | K.PLAYBACK_NOTIFICATION_ID, 49 | DescriptionAdapter(mediaController), 50 | notificationListener 51 | ).apply { 52 | setSmallIcon(R.drawable.ic_microphone) 53 | setMediaSessionToken(sessionToken) 54 | setUseStopAction(true) 55 | setUseNextActionInCompactView(true) 56 | setUsePreviousActionInCompactView(true) 57 | setControlDispatcher(DefaultControlDispatcher(0L, 0L)) 58 | } 59 | } 60 | 61 | private inner class DescriptionAdapter( 62 | private val mediaController: MediaControllerCompat 63 | ) : PlayerNotificationManager.MediaDescriptionAdapter { 64 | override fun createCurrentContentIntent(player: Player): PendingIntent? { 65 | return mediaController.sessionActivity 66 | } 67 | 68 | override fun getCurrentContentText(player: Player): CharSequence { 69 | return mediaController.metadata.description.subtitle.toString() 70 | } 71 | 72 | override fun getCurrentContentTitle(player: Player): CharSequence { 73 | newSongCallback() 74 | return mediaController.metadata.description.title.toString() 75 | } 76 | 77 | override fun getCurrentLargeIcon( 78 | player: Player, 79 | callback: PlayerNotificationManager.BitmapCallback 80 | ): Bitmap? { 81 | Glide.with(context).asBitmap() 82 | .load(mediaController.metadata.description.iconUri) 83 | .diskCacheStrategy(DiskCacheStrategy.ALL) 84 | .into(object : CustomTarget() { 85 | override fun onLoadCleared(placeholder: Drawable?) = Unit 86 | 87 | override fun onResourceReady( 88 | resource: Bitmap, 89 | transition: Transition? 90 | ) { 91 | callback.onBitmap(resource) 92 | } 93 | }) 94 | return null 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/exoplayer/MediaPlayerQueueNavigator.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.exoplayer 2 | 3 | import android.support.v4.media.MediaDescriptionCompat 4 | import android.support.v4.media.session.MediaSessionCompat 5 | import com.google.android.exoplayer2.Player 6 | import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator 7 | 8 | class MediaPlayerQueueNavigator( 9 | mediaSession: MediaSessionCompat, 10 | private val mediaSource: PodcastMediaSource 11 | ) : TimelineQueueNavigator(mediaSession) { 12 | 13 | override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { 14 | return mediaSource.mediaMetadataEpisodes[windowIndex].description 15 | } 16 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/exoplayer/PodcastMediaSource.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.exoplayer 2 | 3 | import android.support.v4.media.MediaBrowserCompat 4 | import android.support.v4.media.MediaDescriptionCompat 5 | import android.support.v4.media.MediaMetadataCompat 6 | import androidx.core.net.toUri 7 | import com.fabirt.podcastapp.domain.model.Episode 8 | import com.google.android.exoplayer2.MediaItem 9 | import com.google.android.exoplayer2.source.ConcatenatingMediaSource 10 | import com.google.android.exoplayer2.source.ProgressiveMediaSource 11 | import com.google.android.exoplayer2.upstream.DataSource 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class PodcastMediaSource @Inject constructor() { 17 | var mediaMetadataEpisodes: List = emptyList() 18 | var podcastEpisodes: List = emptyList() 19 | private set 20 | private val onReadyListeners = mutableListOf() 21 | 22 | private var state: MusicSourceState = 23 | MusicSourceState.CREATED 24 | set(value) { 25 | if (value == MusicSourceState.INITIALIZED || value == MusicSourceState.ERROR) { 26 | synchronized(onReadyListeners) { 27 | field = value 28 | onReadyListeners.forEach { listener -> 29 | listener(isReady) 30 | } 31 | } 32 | } else { 33 | field = value 34 | } 35 | } 36 | 37 | private val isReady: Boolean 38 | get() = state == MusicSourceState.INITIALIZED 39 | 40 | fun setEpisodes(data: List) { 41 | state = MusicSourceState.INITIALIZING 42 | podcastEpisodes = data 43 | mediaMetadataEpisodes = data.map { episode -> 44 | MediaMetadataCompat.Builder() 45 | .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, episode.id) 46 | .putString( 47 | MediaMetadataCompat.METADATA_KEY_ARTIST, 48 | episode.podcast.publisherOriginal 49 | ) 50 | .putString(MediaMetadataCompat.METADATA_KEY_TITLE, episode.titleOriginal) 51 | .putString( 52 | MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, 53 | episode.podcast.titleOriginal 54 | ) 55 | .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, episode.audio) 56 | .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, episode.image) 57 | .build() 58 | } 59 | state = MusicSourceState.INITIALIZED 60 | } 61 | 62 | fun asMediaSource(dataSourceFactory: DataSource.Factory): ConcatenatingMediaSource { 63 | val concatenatingMediaSource = ConcatenatingMediaSource() 64 | mediaMetadataEpisodes.forEach { metadata -> 65 | val mediaItem = MediaItem.fromUri( 66 | metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI).toUri() 67 | ) 68 | val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) 69 | concatenatingMediaSource.addMediaSource(mediaSource) 70 | } 71 | return concatenatingMediaSource 72 | } 73 | 74 | fun asMediaItems() = mediaMetadataEpisodes.map { metadata -> 75 | val description = MediaDescriptionCompat.Builder() 76 | .setMediaId(metadata.description.mediaId) 77 | .setTitle(metadata.description.title) 78 | .setSubtitle(metadata.description.subtitle) 79 | .setIconUri(metadata.description.iconUri) 80 | .setMediaUri(metadata.description.mediaUri) 81 | .build() 82 | MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) 83 | }.toMutableList() 84 | 85 | fun whenReady(listener: OnReadyListener): Boolean { 86 | return if (state == MusicSourceState.CREATED || state == MusicSourceState.INITIALIZING) { 87 | onReadyListeners += listener 88 | false 89 | } else { 90 | listener(isReady) 91 | true 92 | } 93 | } 94 | 95 | fun refresh() { 96 | onReadyListeners.clear() 97 | state = MusicSourceState.CREATED 98 | } 99 | } 100 | 101 | typealias OnReadyListener = (Boolean) -> Unit 102 | 103 | enum class MusicSourceState { 104 | CREATED, 105 | INITIALIZING, 106 | INITIALIZED, 107 | ERROR 108 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/network/client/ListenNotesAPIClient.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.network.client 2 | 3 | import com.fabirt.podcastapp.BuildConfig 4 | import com.fabirt.podcastapp.data.network.constant.ListenNotesAPI 5 | import com.fabirt.podcastapp.data.network.service.PodcastService 6 | import okhttp3.Interceptor 7 | import okhttp3.OkHttpClient 8 | import retrofit2.Retrofit 9 | import retrofit2.converter.gson.GsonConverterFactory 10 | 11 | object ListenNotesAPIClient { 12 | fun createHttpClient(): OkHttpClient { 13 | val requestInterceptor = Interceptor { chain -> 14 | val request = chain.request() 15 | .newBuilder() 16 | .addHeader("X-ListenAPI-Key", BuildConfig.API_KEY) 17 | .build() 18 | 19 | return@Interceptor chain.proceed(request) 20 | } 21 | 22 | val httpClient = OkHttpClient.Builder() 23 | .addInterceptor(requestInterceptor) 24 | 25 | return httpClient.build() 26 | } 27 | 28 | fun createPodcastService( 29 | client: OkHttpClient 30 | ): PodcastService { 31 | return Retrofit.Builder() 32 | .client(client) 33 | .baseUrl(ListenNotesAPI.BASE_URL) 34 | .addConverterFactory(GsonConverterFactory.create()) 35 | .build() 36 | .create(PodcastService::class.java) 37 | } 38 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/network/constant/ListenNotesAPI.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.network.constant 2 | 3 | object ListenNotesAPI { 4 | const val BASE_URL = "https://listen-api.listennotes.com/api/v2/" 5 | const val SEARCH = "search" 6 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/network/model/EpisodeDto.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.network.model 2 | 3 | import com.fabirt.podcastapp.domain.model.Episode 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class EpisodeDto( 7 | val id: String, 8 | val link: String, 9 | val audio: String, 10 | val image: String, 11 | val podcast: PodcastDto, 12 | val thumbnail: String, 13 | @SerializedName("pub_date_ms") 14 | val pubDateMS: Long, 15 | @SerializedName("title_original") 16 | val titleOriginal: String, 17 | @SerializedName("listennotes_url") 18 | val listennotesURL: String, 19 | @SerializedName("audio_length_sec") 20 | val audioLengthSec: Long, 21 | @SerializedName("explicit_content") 22 | val explicitContent: Boolean, 23 | @SerializedName("description_original") 24 | val descriptionOriginal: String, 25 | ) { 26 | 27 | fun asDomainModel() = Episode( 28 | id, 29 | link, 30 | audio, 31 | image, 32 | podcast.asDomainModel(), 33 | thumbnail, 34 | pubDateMS, 35 | titleOriginal, 36 | listennotesURL, 37 | audioLengthSec, 38 | explicitContent, 39 | descriptionOriginal 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/network/model/PodcastDto.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.network.model 2 | 3 | import com.fabirt.podcastapp.domain.model.Podcast 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class PodcastDto( 7 | val id: String, 8 | val image: String, 9 | val thumbnail: String, 10 | @SerializedName("title_original") 11 | val titleOriginal: String, 12 | @SerializedName("listennotes_url") 13 | val listennotesURL: String, 14 | @SerializedName("publisher_original") 15 | val publisherOriginal: String 16 | ) { 17 | 18 | fun asDomainModel() = Podcast( 19 | id, 20 | image, 21 | thumbnail, 22 | titleOriginal, 23 | listennotesURL, 24 | publisherOriginal 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/network/model/PodcastSearchDto.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.network.model 2 | 3 | import com.fabirt.podcastapp.domain.model.PodcastSearch 4 | 5 | data class PodcastSearchDto( 6 | val count: Long, 7 | val total: Long, 8 | val results: List 9 | ) { 10 | 11 | fun asDomainModel() = PodcastSearch( 12 | count, 13 | total, 14 | results.map { it.asDomainModel() } 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/network/service/PodcastService.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.network.service 2 | 3 | import com.fabirt.podcastapp.data.network.constant.ListenNotesAPI 4 | import com.fabirt.podcastapp.data.network.model.PodcastSearchDto 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | interface PodcastService { 9 | 10 | @GET(ListenNotesAPI.SEARCH) 11 | suspend fun searchPodcasts( 12 | @Query("q") query: String, 13 | @Query("type") type: String, 14 | ): PodcastSearchDto 15 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/service/MediaPlayerService.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.service 2 | 3 | import android.app.PendingIntent 4 | import android.app.Service 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.support.v4.media.MediaBrowserCompat 8 | import android.support.v4.media.MediaMetadataCompat 9 | import android.support.v4.media.session.MediaSessionCompat 10 | import android.util.Log 11 | import androidx.media.MediaBrowserServiceCompat 12 | import com.fabirt.podcastapp.constant.K 13 | import com.fabirt.podcastapp.data.exoplayer.* 14 | import com.fabirt.podcastapp.ui.MainActivity 15 | import com.google.android.exoplayer2.SimpleExoPlayer 16 | import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector 17 | import com.google.android.exoplayer2.upstream.cache.CacheDataSource 18 | import dagger.hilt.android.AndroidEntryPoint 19 | import kotlinx.coroutines.CoroutineScope 20 | import kotlinx.coroutines.Dispatchers 21 | import kotlinx.coroutines.Job 22 | import kotlinx.coroutines.cancel 23 | import javax.inject.Inject 24 | 25 | @AndroidEntryPoint 26 | class MediaPlayerService : MediaBrowserServiceCompat() { 27 | @Inject 28 | lateinit var dataSourceFactory: CacheDataSource.Factory 29 | 30 | @Inject 31 | lateinit var exoPlayer: SimpleExoPlayer 32 | 33 | @Inject 34 | lateinit var mediaSource: PodcastMediaSource 35 | 36 | private val serviceJob = Job() 37 | private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) 38 | 39 | private lateinit var mediaSession: MediaSessionCompat 40 | private lateinit var mediaSessionConnector: MediaSessionConnector 41 | 42 | private lateinit var mediaPlayerNotificationManager: MediaPlayerNotificationManager 43 | 44 | private var currentPlayingMedia: MediaMetadataCompat? = null 45 | 46 | private var isPlayerInitialized = false 47 | 48 | var isForegroundService: Boolean = false 49 | 50 | companion object { 51 | private const val TAG = "MediaPlayerService" 52 | 53 | var currentDuration: Long = 0L 54 | private set 55 | } 56 | 57 | override fun onCreate() { 58 | super.onCreate() 59 | Log.i(TAG, "onCreate called") 60 | val activityPendingIntent = Intent(this, MainActivity::class.java) 61 | .apply { 62 | action = K.ACTION_PODCAST_NOTIFICATION_CLICK 63 | } 64 | .let { 65 | PendingIntent.getActivity( 66 | this, 67 | 0, 68 | it, 69 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 70 | ) 71 | } 72 | 73 | mediaSession = MediaSessionCompat(this, TAG).apply { 74 | setSessionActivity(activityPendingIntent) 75 | isActive = true 76 | } 77 | 78 | val mediaPlaybackPreparer = MediaPlaybackPreparer(mediaSource) { mediaMetadata -> 79 | currentPlayingMedia = mediaMetadata 80 | preparePlayer(mediaSource.mediaMetadataEpisodes, mediaMetadata, true) 81 | } 82 | mediaSessionConnector = MediaSessionConnector(mediaSession).apply { 83 | setPlaybackPreparer(mediaPlaybackPreparer) 84 | setQueueNavigator(MediaPlayerQueueNavigator(mediaSession, mediaSource)) 85 | setPlayer(exoPlayer) 86 | } 87 | 88 | this.sessionToken = mediaSession.sessionToken 89 | 90 | mediaPlayerNotificationManager = MediaPlayerNotificationManager( 91 | this, 92 | mediaSession.sessionToken, 93 | MediaPlayerNotificationListener(this) 94 | ) { 95 | currentDuration = exoPlayer.duration 96 | } 97 | } 98 | 99 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 100 | return Service.START_STICKY 101 | } 102 | 103 | override fun onCustomAction(action: String, extras: Bundle?, result: Result) { 104 | super.onCustomAction(action, extras, result) 105 | when (action) { 106 | K.START_MEDIA_PLAYBACK_ACTION -> { 107 | mediaPlayerNotificationManager.showNotification(exoPlayer) 108 | } 109 | K.REFRESH_MEDIA_BROWSER_CHILDREN -> { 110 | mediaSource.refresh() 111 | notifyChildrenChanged(K.MEDIA_ROOT_ID) 112 | } 113 | else -> Unit 114 | } 115 | } 116 | 117 | override fun onGetRoot( 118 | clientPackageName: String, 119 | clientUid: Int, 120 | rootHints: Bundle? 121 | ): BrowserRoot { 122 | return BrowserRoot(K.MEDIA_ROOT_ID, null) 123 | } 124 | 125 | override fun onLoadChildren( 126 | parentId: String, 127 | result: Result> 128 | ) { 129 | Log.i(TAG, "onLoadChildren called") 130 | when (parentId) { 131 | K.MEDIA_ROOT_ID -> { 132 | val resultsSent = mediaSource.whenReady { isInitialized -> 133 | if (isInitialized) { 134 | 135 | result.sendResult(mediaSource.asMediaItems()) 136 | if (!isPlayerInitialized && mediaSource.mediaMetadataEpisodes.isNotEmpty()) { 137 | isPlayerInitialized = true 138 | } 139 | } else { 140 | result.sendResult(null) 141 | } 142 | } 143 | if (!resultsSent) { 144 | result.detach() 145 | } 146 | } 147 | else -> Unit 148 | } 149 | } 150 | 151 | override fun onDestroy() { 152 | super.onDestroy() 153 | serviceScope.cancel() 154 | exoPlayer.release() 155 | } 156 | 157 | private fun preparePlayer( 158 | mediaMetaData: List, 159 | itemToPlay: MediaMetadataCompat?, 160 | playWhenReady: Boolean 161 | ) { 162 | val indexToPlay = if (currentPlayingMedia == null) 0 else mediaMetaData.indexOf(itemToPlay) 163 | exoPlayer.setMediaSource(mediaSource.asMediaSource(dataSourceFactory)) 164 | exoPlayer.prepare() 165 | exoPlayer.seekTo(indexToPlay, 0L) 166 | exoPlayer.playWhenReady = playWhenReady 167 | } 168 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/data/service/MediaPlayerServiceConnection.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.data.service 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.support.v4.media.MediaBrowserCompat 6 | import android.support.v4.media.MediaMetadataCompat 7 | import android.support.v4.media.session.MediaControllerCompat 8 | import android.support.v4.media.session.PlaybackStateCompat 9 | import androidx.compose.runtime.mutableStateOf 10 | import com.fabirt.podcastapp.constant.K 11 | import com.fabirt.podcastapp.data.exoplayer.PodcastMediaSource 12 | import com.fabirt.podcastapp.domain.model.Episode 13 | import com.fabirt.podcastapp.util.currentPosition 14 | 15 | class MediaPlayerServiceConnection( 16 | context: Context, 17 | private val mediaSource: PodcastMediaSource, 18 | ) { 19 | 20 | var playbackState = mutableStateOf(null) 21 | var currentPlayingEpisode = mutableStateOf(null) 22 | 23 | lateinit var mediaController: MediaControllerCompat 24 | 25 | private var isConnected: Boolean = false 26 | 27 | val transportControls: MediaControllerCompat.TransportControls 28 | get() = mediaController.transportControls 29 | 30 | private val mediaBrowserConnectionCallback = MediaBrowserConnectionCallback(context) 31 | 32 | private val mediaBrowser = MediaBrowserCompat( 33 | context, 34 | ComponentName(context, MediaPlayerService::class.java), 35 | mediaBrowserConnectionCallback, 36 | null 37 | ).apply { 38 | connect() 39 | } 40 | 41 | fun playPodcast(episodes: List) { 42 | mediaSource.setEpisodes(episodes) 43 | mediaBrowser.sendCustomAction(K.START_MEDIA_PLAYBACK_ACTION, null, null) 44 | } 45 | 46 | fun fastForward(seconds: Int = 10) { 47 | playbackState.value?.currentPosition?.let { currentPosition -> 48 | transportControls.seekTo(currentPosition + seconds * 1000) 49 | } 50 | } 51 | 52 | fun rewind(seconds: Int = 10) { 53 | playbackState.value?.currentPosition?.let { currentPosition -> 54 | transportControls.seekTo(currentPosition - seconds * 1000) 55 | } 56 | } 57 | 58 | fun subscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { 59 | mediaBrowser.subscribe(parentId, callback) 60 | } 61 | 62 | fun unsubscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { 63 | mediaBrowser.unsubscribe(parentId, callback) 64 | } 65 | 66 | fun refreshMediaBrowserChildren() { 67 | mediaBrowser.sendCustomAction(K.REFRESH_MEDIA_BROWSER_CHILDREN, null, null) 68 | } 69 | 70 | private inner class MediaBrowserConnectionCallback( 71 | private val context: Context 72 | ) : MediaBrowserCompat.ConnectionCallback() { 73 | override fun onConnected() { 74 | super.onConnected() 75 | isConnected = true 76 | mediaController = MediaControllerCompat(context, mediaBrowser.sessionToken).apply { 77 | registerCallback(MediaControllerCallback()) 78 | } 79 | } 80 | 81 | override fun onConnectionSuspended() { 82 | super.onConnectionSuspended() 83 | isConnected = false 84 | } 85 | 86 | override fun onConnectionFailed() { 87 | super.onConnectionFailed() 88 | isConnected = false 89 | } 90 | } 91 | 92 | private inner class MediaControllerCallback : MediaControllerCompat.Callback() { 93 | override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { 94 | super.onPlaybackStateChanged(state) 95 | playbackState.value = state 96 | } 97 | 98 | override fun onMetadataChanged(metadata: MediaMetadataCompat?) { 99 | super.onMetadataChanged(metadata) 100 | currentPlayingEpisode.value = metadata?.let { 101 | mediaSource.podcastEpisodes.find { 102 | it.id == metadata.description?.mediaId 103 | } 104 | } 105 | } 106 | 107 | override fun onSessionDestroyed() { 108 | super.onSessionDestroyed() 109 | mediaBrowserConnectionCallback.onConnectionSuspended() 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.di 2 | 3 | import android.content.Context 4 | import com.fabirt.podcastapp.data.datastore.PodcastDataStore 5 | import com.fabirt.podcastapp.data.exoplayer.PodcastMediaSource 6 | import com.fabirt.podcastapp.data.network.client.ListenNotesAPIClient 7 | import com.fabirt.podcastapp.data.network.service.PodcastService 8 | import com.fabirt.podcastapp.data.service.MediaPlayerServiceConnection 9 | import com.fabirt.podcastapp.domain.repository.PodcastRepository 10 | import com.fabirt.podcastapp.domain.repository.PodcastRepositoryImpl 11 | import dagger.Module 12 | import dagger.Provides 13 | import dagger.hilt.InstallIn 14 | import dagger.hilt.android.qualifiers.ApplicationContext 15 | import dagger.hilt.components.SingletonComponent 16 | import okhttp3.OkHttpClient 17 | import javax.inject.Singleton 18 | 19 | @Module 20 | @InstallIn(SingletonComponent::class) 21 | object AppModule { 22 | 23 | @Provides 24 | fun provideHttpClient(): OkHttpClient = ListenNotesAPIClient.createHttpClient() 25 | 26 | @Provides 27 | @Singleton 28 | fun providePodcastService( 29 | client: OkHttpClient 30 | ): PodcastService = ListenNotesAPIClient.createPodcastService(client) 31 | 32 | @Provides 33 | @Singleton 34 | fun providePodcastDataStore( 35 | @ApplicationContext context: Context 36 | ): PodcastDataStore = PodcastDataStore(context) 37 | 38 | @Provides 39 | @Singleton 40 | fun providePodcastRepository( 41 | service: PodcastService, 42 | dataStore: PodcastDataStore 43 | ): PodcastRepository = PodcastRepositoryImpl(service, dataStore) 44 | 45 | @Provides 46 | @Singleton 47 | fun provideMediaPlayerServiceConnection( 48 | @ApplicationContext context: Context, 49 | mediaSource: PodcastMediaSource 50 | ): MediaPlayerServiceConnection = MediaPlayerServiceConnection(context, mediaSource) 51 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/di/ServiceModule.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.di 2 | 3 | import android.content.Context 4 | import com.google.android.exoplayer2.C 5 | import com.google.android.exoplayer2.SimpleExoPlayer 6 | import com.google.android.exoplayer2.audio.AudioAttributes 7 | import com.google.android.exoplayer2.database.ExoDatabaseProvider 8 | import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory 9 | import com.google.android.exoplayer2.upstream.cache.CacheDataSource 10 | import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor 11 | import com.google.android.exoplayer2.upstream.cache.SimpleCache 12 | import com.google.android.exoplayer2.util.Util 13 | import dagger.Module 14 | import dagger.Provides 15 | import dagger.hilt.InstallIn 16 | import dagger.hilt.android.components.ServiceComponent 17 | import dagger.hilt.android.qualifiers.ApplicationContext 18 | import dagger.hilt.android.scopes.ServiceScoped 19 | import java.io.File 20 | 21 | @Module 22 | @InstallIn(ServiceComponent::class) 23 | object ServiceModule { 24 | 25 | @Provides 26 | @ServiceScoped 27 | fun provideAudioAttributes(): AudioAttributes = 28 | AudioAttributes.Builder() 29 | .setContentType(C.CONTENT_TYPE_MUSIC) 30 | .setUsage(C.USAGE_MEDIA) 31 | .build() 32 | 33 | @Provides 34 | @ServiceScoped 35 | fun provideExoPlayer( 36 | @ApplicationContext context: Context, 37 | audioAttributes: AudioAttributes 38 | ): SimpleExoPlayer = SimpleExoPlayer.Builder(context) 39 | .build() 40 | .apply { 41 | setAudioAttributes(audioAttributes, true) 42 | setHandleAudioBecomingNoisy(true) 43 | 44 | } 45 | 46 | @Provides 47 | @ServiceScoped 48 | fun provideDataSourceFactory( 49 | @ApplicationContext context: Context 50 | ) = DefaultDataSourceFactory(context, Util.getUserAgent(context, context.packageName)) 51 | 52 | @Provides 53 | @ServiceScoped 54 | fun provideCacheDataSourceFactory( 55 | @ApplicationContext context: Context, 56 | datasourceFactory: DefaultDataSourceFactory 57 | ): CacheDataSource.Factory { 58 | val cacheDir = File(context.cacheDir, "media") 59 | val databaseProvider = ExoDatabaseProvider(context) 60 | val cache = SimpleCache(cacheDir, NoOpCacheEvictor(), databaseProvider) 61 | return CacheDataSource.Factory().apply { 62 | setCache(cache) 63 | setUpstreamDataSourceFactory(datasourceFactory) 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/domain/model/Episode.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.domain.model 2 | 3 | data class Episode( 4 | val id: String, 5 | val link: String, 6 | val audio: String, 7 | val image: String, 8 | val podcast: Podcast, 9 | val thumbnail: String, 10 | val pubDateMS: Long, 11 | val titleOriginal: String, 12 | val listennotesURL: String, 13 | val audioLengthSec: Long, 14 | val explicitContent: Boolean, 15 | val descriptionOriginal: String, 16 | ) 17 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/domain/model/Podcast.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.domain.model 2 | 3 | data class Podcast ( 4 | val id: String, 5 | val image: String, 6 | val thumbnail: String, 7 | val titleOriginal: String, 8 | val listennotesURL: String, 9 | val publisherOriginal: String 10 | ) 11 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/domain/model/PodcastSearch.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.domain.model 2 | 3 | data class PodcastSearch( 4 | val count: Long, 5 | val total: Long, 6 | val results: List 7 | ) -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/domain/repository/PodcastRepository.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.domain.repository 2 | 3 | import com.fabirt.podcastapp.domain.model.PodcastSearch 4 | import com.fabirt.podcastapp.error.Failure 5 | import com.fabirt.podcastapp.util.Either 6 | 7 | interface PodcastRepository { 8 | 9 | suspend fun searchPodcasts( 10 | query: String, 11 | type: String, 12 | ): Either 13 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/domain/repository/PodcastRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.domain.repository 2 | 3 | import com.fabirt.podcastapp.data.datastore.PodcastDataStore 4 | import com.fabirt.podcastapp.data.network.service.PodcastService 5 | import com.fabirt.podcastapp.domain.model.PodcastSearch 6 | import com.fabirt.podcastapp.error.Failure 7 | import com.fabirt.podcastapp.util.Either 8 | import com.fabirt.podcastapp.util.left 9 | import com.fabirt.podcastapp.util.right 10 | 11 | class PodcastRepositoryImpl( 12 | private val service: PodcastService, 13 | private val dataStore: PodcastDataStore 14 | ) : PodcastRepository { 15 | 16 | companion object { 17 | private const val TAG = "PodcastRepository" 18 | } 19 | 20 | override suspend fun searchPodcasts( 21 | query: String, 22 | type: String 23 | ): Either { 24 | return try { 25 | val canFetchAPI = dataStore.canFetchAPI() 26 | if (canFetchAPI) { 27 | val result = service.searchPodcasts(query, type).asDomainModel() 28 | dataStore.storePodcastSearchResult(result) 29 | right(result) 30 | } else { 31 | right(dataStore.readLastPodcastSearchResult()) 32 | } 33 | } catch (e: Exception) { 34 | left(Failure.UnexpectedFailure) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/domain/repository/PodcastRepositoryMockImpl.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.domain.repository 2 | 3 | import com.fabirt.podcastapp.domain.model.Episode 4 | import com.fabirt.podcastapp.domain.model.Podcast 5 | import com.fabirt.podcastapp.domain.model.PodcastSearch 6 | import com.fabirt.podcastapp.error.Failure 7 | import com.fabirt.podcastapp.util.Either 8 | import com.fabirt.podcastapp.util.left 9 | import com.fabirt.podcastapp.util.right 10 | import kotlinx.coroutines.delay 11 | 12 | class PodcastRepositoryMockImpl : PodcastRepository { 13 | override suspend fun searchPodcasts( 14 | query: String, 15 | type: String 16 | ): Either { 17 | delay(5000) 18 | //return left(Failure.UnexpectedFailure) 19 | return right(demoData()) 20 | } 21 | 22 | private fun demoData(): PodcastSearch { 23 | val podcast = Podcast( 24 | id = "8758da9be6c8452884a8cab6373b007c", 25 | image = "https://cdn-images-1.listennotes.com/podcasts/the-rough-cut-PmR84dsqcbj-53MLh7NpAwm.1400x1400.jpg", 26 | thumbnail = "https://cdn-images-1.listennotes.com/podcasts/the-rough-cut-AzKVtPeMOL4-53MLh7NpAwm.300x300.jpg", 27 | titleOriginal = "The Rough Cut", 28 | listennotesURL = "https://www.listennotes.com/c/8758da9be6c8452884a8cab6373b007c/", 29 | publisherOriginal = "Matt Feury" 30 | ) 31 | 32 | return PodcastSearch( 33 | count = 10, 34 | total = 9000, 35 | results = (1..20).map { 36 | Episode( 37 | id = "ea09b575d07341599d8d5b71f205517b$it", 38 | link = "http://theroughcutpod.com/?p=786&utm_source=listennotes.com&utm_campaign=Listen+Notes&utm_medium=website", 39 | audio = "https://www.listennotes.com/e/p/ea09b575d07341599d8d5b71f205517b/", 40 | image = "https://cdn-images-1.listennotes.com/podcasts/the-rough-cut-PmR84dsqcbj-53MLh7NpAwm.1400x1400.jpg", 41 | podcast = podcast, 42 | thumbnail = "https://cdn-images-1.listennotes.com/podcasts/the-rough-cut-AzKVtPeMOL4-53MLh7NpAwm.300x300.jpg", 43 | pubDateMS = 1579507216047, 44 | titleOriginal = "Star Wars - The Force Awakens", 45 | listennotesURL = "https://www.listennotes.com/e/ea09b575d07341599d8d5b71f205517b/", 46 | audioLengthSec = 1694, 47 | explicitContent = false, 48 | descriptionOriginal = "In this episode of The Rough Cut we close out our study of the final Skywalker trilogy with a look back on the film that helped the dormant franchise make the jump to lightspeed, Episode VII - The Force Awakens.  Recorded in Amsterdam in front of a festival audience in 2018, editor Maryann Brandon ACE recounts her work on The Force Awakens just as she was about to begin editing what would come to be known as Episode IX - The Rise of Skywalker.   Go back to the beginning and listen to our podcast with Star Wars and 'Empire' editor, Paul Hirsch. Hear editor Bob Ducsay talk about cutting The Last Jedi. Listen to Maryann Brandon talk about her work on The Rise of Skywalker. Get your hands on the non-linear editor behind the latest Skywalker trilogy,  Avid Media Composer! Subscribe to The Rough Cut for more great interviews with the heroes of the editing room!    " 49 | ) 50 | } 51 | ) 52 | } 53 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/error/Failure.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.error 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.res.stringResource 6 | import com.fabirt.podcastapp.R 7 | 8 | sealed class Failure(@StringRes val key: Int) { 9 | 10 | object UnexpectedFailure : Failure(R.string.unexpected_error) 11 | 12 | @Composable 13 | fun translate(): String { 14 | return stringResource(key) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.OnBackPressedDispatcher 6 | import androidx.activity.compose.setContent 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.core.view.WindowCompat 13 | import androidx.navigation.compose.NavHost 14 | import androidx.navigation.compose.composable 15 | import androidx.navigation.navDeepLink 16 | import com.fabirt.podcastapp.R 17 | import com.fabirt.podcastapp.constant.K 18 | import com.fabirt.podcastapp.ui.common.ProvideMultiViewModel 19 | import com.fabirt.podcastapp.ui.home.HomeScreen 20 | import com.fabirt.podcastapp.ui.navigation.Destination 21 | import com.fabirt.podcastapp.ui.navigation.Navigator 22 | import com.fabirt.podcastapp.ui.navigation.ProvideNavHostController 23 | import com.fabirt.podcastapp.ui.podcast.PodcastBottomBar 24 | import com.fabirt.podcastapp.ui.podcast.PodcastDetailScreen 25 | import com.fabirt.podcastapp.ui.podcast.PodcastPlayerScreen 26 | import com.fabirt.podcastapp.ui.theme.PodcastAppTheme 27 | import com.fabirt.podcastapp.ui.welcome.WelcomeScreen 28 | import com.google.accompanist.insets.ProvideWindowInsets 29 | import dagger.hilt.android.AndroidEntryPoint 30 | 31 | @AndroidEntryPoint 32 | class MainActivity : ComponentActivity() { 33 | 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | super.onCreate(savedInstanceState) 36 | setTheme(R.style.Theme_PodcastApp) 37 | WindowCompat.setDecorFitsSystemWindows(window, false) 38 | var startDestination = Destination.welcome 39 | if (intent?.action == K.ACTION_PODCAST_NOTIFICATION_CLICK) { 40 | startDestination = Destination.home 41 | } 42 | 43 | setContent { 44 | PodcastApp( 45 | startDestination = startDestination, 46 | backDispatcher = onBackPressedDispatcher 47 | ) 48 | } 49 | } 50 | } 51 | 52 | @Composable 53 | fun PodcastApp( 54 | startDestination: String = Destination.welcome, 55 | backDispatcher: OnBackPressedDispatcher 56 | ) { 57 | PodcastAppTheme { 58 | ProvideWindowInsets { 59 | ProvideMultiViewModel { 60 | ProvideNavHostController { 61 | Box( 62 | modifier = Modifier.fillMaxSize() 63 | ) { 64 | NavHost(Navigator.current, startDestination) { 65 | composable(Destination.welcome) { WelcomeScreen() } 66 | 67 | composable(Destination.home) { 68 | HomeScreen() 69 | } 70 | 71 | composable( 72 | Destination.podcast, 73 | deepLinks = listOf(navDeepLink { uriPattern = "https://www.listennotes.com/e/{id}" }) 74 | ) { backStackEntry -> 75 | PodcastDetailScreen( 76 | podcastId = backStackEntry.arguments?.getString("id")!!, 77 | ) 78 | } 79 | } 80 | PodcastBottomBar( 81 | modifier = Modifier.align(Alignment.BottomCenter) 82 | ) 83 | PodcastPlayerScreen(backDispatcher) 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/BackButton.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.ArrowBack 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.unit.dp 14 | import com.fabirt.podcastapp.R 15 | import com.fabirt.podcastapp.ui.navigation.Navigator 16 | 17 | @Composable 18 | fun BackButton() { 19 | val navController = Navigator.current 20 | Icon( 21 | Icons.Default.ArrowBack, 22 | contentDescription = stringResource(R.string.back), 23 | modifier = Modifier 24 | .padding(top = 8.dp) 25 | .clip(CircleShape) 26 | .clickable { 27 | navController.navigateUp() 28 | } 29 | .padding(16.dp) 30 | ) 31 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/EmphasisText.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.material.ContentAlpha 4 | import androidx.compose.material.LocalContentAlpha 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.ui.text.TextStyle 10 | 11 | @Composable 12 | fun EmphasisText( 13 | text: String, 14 | contentAlpha: Float = ContentAlpha.medium, 15 | style: TextStyle = MaterialTheme.typography.body2 16 | ) { 17 | CompositionLocalProvider(LocalContentAlpha provides contentAlpha) { 18 | Text( 19 | text, 20 | style = style 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/IconButton.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material.Icon 7 | import androidx.compose.material.LocalContentAlpha 8 | import androidx.compose.material.LocalContentColor 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.vector.ImageVector 14 | import androidx.compose.ui.unit.Dp 15 | import androidx.compose.ui.unit.dp 16 | 17 | @Composable 18 | fun IconButton( 19 | imageVector: ImageVector, 20 | contentDescription: String, 21 | modifier: Modifier = Modifier, 22 | padding: Dp = 16.dp, 23 | tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), 24 | onClick: () -> Unit 25 | ) { 26 | Icon( 27 | imageVector = imageVector, 28 | contentDescription = contentDescription, 29 | tint = tint, 30 | modifier = modifier 31 | .clip(CircleShape) 32 | .clickable(onClick = onClick) 33 | .padding(padding) 34 | ) 35 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/PreviewContent.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.fabirt.podcastapp.ui.navigation.ProvideNavHostController 5 | import com.fabirt.podcastapp.ui.theme.PodcastAppTheme 6 | import com.google.accompanist.insets.ProvideWindowInsets 7 | 8 | @Composable 9 | fun PreviewContent( 10 | darkTheme: Boolean = false, 11 | content: @Composable () -> Unit 12 | ) { 13 | PodcastAppTheme(darkTheme = darkTheme) { 14 | ProvideWindowInsets { 15 | ProvideNavHostController { 16 | content() 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/PrimaryButton.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.defaultMinSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | fun PrimaryButton( 21 | text: String, 22 | height: Dp = 58.dp, 23 | onClick: () -> Unit 24 | ) { 25 | Box( 26 | modifier = Modifier 27 | .defaultMinSize(200.dp) 28 | .height(height) 29 | .clip(CircleShape) 30 | .background(MaterialTheme.colors.primary) 31 | .clickable(onClick = onClick) 32 | ) { 33 | Text( 34 | text = text, 35 | color = MaterialTheme.colors.onPrimary, 36 | style = MaterialTheme.typography.button, 37 | modifier = Modifier 38 | .padding(horizontal = 16.dp) 39 | .align(Alignment.Center) 40 | ) 41 | } 42 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/StaggeredVerticalGrid.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.layout.Layout 6 | import androidx.compose.ui.unit.Dp 7 | import androidx.compose.ui.unit.dp 8 | 9 | @Composable 10 | fun StaggeredVerticalGrid( 11 | modifier: Modifier = Modifier, 12 | crossAxisCount: Int, 13 | spacing: Dp = 0.dp, 14 | content: @Composable () -> Unit 15 | ) { 16 | Layout( 17 | content = content, 18 | modifier = modifier 19 | ) { measurables, constraints -> 20 | check(constraints.hasBoundedWidth) { 21 | "Unbounded width not supported" 22 | } 23 | val spacingWidth = spacing.roundToPx() / crossAxisCount 24 | val columnWidth = constraints.maxWidth / crossAxisCount 25 | val itemConstraints = 26 | constraints.copy(maxWidth = columnWidth - spacingWidth) 27 | val colHeights = IntArray(crossAxisCount) { 0 } // track each column's height 28 | val placeables = measurables.map { measurable -> 29 | val column = shortestColumn(colHeights) 30 | val placeable = measurable.measure(itemConstraints) 31 | colHeights[column] += placeable.height 32 | placeable 33 | } 34 | 35 | val height = colHeights.maxOrNull()?.coerceIn(constraints.minHeight, constraints.maxHeight) 36 | ?: constraints.minHeight 37 | layout( 38 | width = constraints.maxWidth, 39 | height = height 40 | ) { 41 | val colY = IntArray(crossAxisCount) { 0 } 42 | placeables.forEachIndexed { index, placeable -> 43 | val column = shortestColumn(colY) 44 | val offset = if (column > 0) spacingWidth else 0 45 | 46 | placeable.place( 47 | x = (columnWidth + offset) * column, 48 | y = colY[column] 49 | ) 50 | colY[column] += placeable.height 51 | } 52 | } 53 | } 54 | } 55 | 56 | private fun shortestColumn(colHeights: IntArray): Int { 57 | var minHeight = Int.MAX_VALUE 58 | var column = 0 59 | colHeights.forEachIndexed { index, height -> 60 | if (height < minHeight) { 61 | minHeight = height 62 | column = index 63 | } 64 | } 65 | return column 66 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/common/ViewModelProvider.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.common 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | import androidx.lifecycle.viewmodel.compose.viewModel 7 | import com.fabirt.podcastapp.ui.viewmodel.PodcastDetailViewModel 8 | import com.fabirt.podcastapp.ui.viewmodel.PodcastPlayerViewModel 9 | import com.fabirt.podcastapp.ui.viewmodel.PodcastSearchViewModel 10 | 11 | object ViewModelProvider { 12 | val podcastSearch: PodcastSearchViewModel 13 | @Composable 14 | get() = LocalPodcastSearchViewModel.current 15 | 16 | val podcastDetail: PodcastDetailViewModel 17 | @Composable 18 | get() = LocalPodcastDetailViewModel.current 19 | 20 | val podcastPlayer: PodcastPlayerViewModel 21 | @Composable 22 | get() = LocalPodcastPlayerViewModel.current 23 | } 24 | 25 | @Composable 26 | fun ProvideMultiViewModel(content: @Composable () -> Unit) { 27 | val viewModel1: PodcastSearchViewModel = viewModel() 28 | val viewModel2: PodcastDetailViewModel = viewModel() 29 | val viewModel3: PodcastPlayerViewModel = viewModel() 30 | 31 | CompositionLocalProvider( 32 | LocalPodcastSearchViewModel provides viewModel1, 33 | ) { 34 | CompositionLocalProvider( 35 | LocalPodcastDetailViewModel provides viewModel2, 36 | ) { 37 | CompositionLocalProvider( 38 | LocalPodcastPlayerViewModel provides viewModel3, 39 | ) { 40 | content() 41 | } 42 | } 43 | } 44 | } 45 | 46 | private val LocalPodcastSearchViewModel = staticCompositionLocalOf { 47 | error("No PodcastSearchViewModel provided") 48 | } 49 | 50 | private val LocalPodcastDetailViewModel = staticCompositionLocalOf { 51 | error("No PodcastDetailViewModel provided") 52 | } 53 | 54 | private val LocalPodcastPlayerViewModel = staticCompositionLocalOf { 55 | error("No PodcastPlayerViewModel provided") 56 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/home/ErrorView.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.home 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.MaterialTheme 6 | import androidx.compose.material.Text 7 | import androidx.compose.material.TextButton 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.unit.dp 12 | import com.fabirt.podcastapp.R 13 | 14 | @Composable 15 | fun ErrorView( 16 | text: String, 17 | onClick: () -> Unit 18 | ) { 19 | Column( 20 | modifier = Modifier.padding(horizontal = 24.dp) 21 | ) { 22 | Text( 23 | text, 24 | modifier = Modifier 25 | .padding(horizontal = 8.dp) 26 | .padding(bottom = 8.dp) 27 | ) 28 | TextButton(onClick = onClick) { 29 | Text( 30 | stringResource(R.string.try_again), 31 | style = MaterialTheme.typography.button, 32 | ) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.home 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.lazy.rememberLazyListState 7 | import androidx.compose.material.Surface 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import androidx.compose.ui.unit.dp 12 | import androidx.navigation.NavHostController 13 | import com.fabirt.podcastapp.domain.model.Episode 14 | import com.fabirt.podcastapp.ui.common.PreviewContent 15 | import com.fabirt.podcastapp.ui.common.StaggeredVerticalGrid 16 | import com.fabirt.podcastapp.ui.common.ViewModelProvider 17 | import com.fabirt.podcastapp.ui.navigation.Destination 18 | import com.fabirt.podcastapp.ui.navigation.Navigator 19 | import com.fabirt.podcastapp.util.Resource 20 | import com.google.accompanist.insets.navigationBarsPadding 21 | 22 | @Composable 23 | fun HomeScreen() { 24 | val scrollState = rememberLazyListState() 25 | val navController = Navigator.current 26 | val podcastSearchViewModel = ViewModelProvider.podcastSearch 27 | val podcastSearch = podcastSearchViewModel.podcastSearch 28 | 29 | Surface { 30 | LazyColumn(state = scrollState) { 31 | item { 32 | LargeTitle() 33 | } 34 | 35 | when (podcastSearch) { 36 | is Resource.Error -> { 37 | item { 38 | ErrorView(text = podcastSearch.failure.translate()) { 39 | podcastSearchViewModel.searchPodcasts() 40 | } 41 | } 42 | } 43 | Resource.Loading -> { 44 | item { 45 | LoadingPlaceholder() 46 | } 47 | } 48 | is Resource.Success -> { 49 | item { 50 | StaggeredVerticalGrid( 51 | crossAxisCount = 2, 52 | spacing = 16.dp, 53 | modifier = Modifier.padding(horizontal = 16.dp) 54 | ) { 55 | podcastSearch.data.results.forEach { podcast -> 56 | PodcastView( 57 | podcast = podcast, 58 | modifier = Modifier.padding(bottom = 16.dp) 59 | ) { 60 | openPodcastDetail(navController, podcast) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | item { 69 | Box( 70 | modifier = Modifier 71 | .navigationBarsPadding() 72 | .padding(bottom = 32.dp) 73 | .padding(bottom = if (ViewModelProvider.podcastPlayer.currentPlayingEpisode.value != null) 64.dp else 0.dp) 74 | ) 75 | } 76 | } 77 | } 78 | } 79 | 80 | private fun openPodcastDetail( 81 | navController: NavHostController, 82 | podcast: Episode 83 | ) { 84 | navController.navigate(Destination.podcast(podcast.id)) { } 85 | } 86 | 87 | @Composable 88 | @Preview(name = "Home") 89 | fun HomeScreenPreview() { 90 | PreviewContent { 91 | HomeScreen() 92 | } 93 | } 94 | 95 | @Composable 96 | @Preview(name = "Home (Dark)") 97 | fun HomeScreenDarkPreview() { 98 | PreviewContent(darkTheme = true) { 99 | HomeScreen() 100 | } 101 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/home/LargeTitle.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.home 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.unit.dp 13 | import com.fabirt.podcastapp.R 14 | import com.google.accompanist.insets.statusBarsPadding 15 | 16 | @Composable 17 | fun LargeTitle() { 18 | Box( 19 | modifier = Modifier 20 | .statusBarsPadding() 21 | .padding(horizontal = 24.dp) 22 | .padding(bottom = 32.dp) 23 | .height(120.dp) 24 | ) { 25 | Text( 26 | stringResource(R.string.trending_now), 27 | style = MaterialTheme.typography.h1, 28 | modifier = Modifier.align(Alignment.BottomCenter) 29 | ) 30 | } 31 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/home/LoadingPlaceholder.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.home 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.aspectRatio 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.unit.dp 13 | import com.fabirt.podcastapp.ui.common.StaggeredVerticalGrid 14 | 15 | @Composable 16 | fun LoadingPlaceholder() { 17 | StaggeredVerticalGrid( 18 | crossAxisCount = 2, 19 | spacing = 16.dp, 20 | modifier = Modifier.padding(horizontal = 16.dp) 21 | ) { 22 | (1..10).map { 23 | Column( 24 | modifier = Modifier 25 | .clip(MaterialTheme.shapes.medium) 26 | .background(MaterialTheme.colors.background) 27 | ) { 28 | Box( 29 | Modifier 30 | .clip(MaterialTheme.shapes.medium) 31 | .aspectRatio(1f) 32 | .background(MaterialTheme.colors.onBackground.copy(alpha = 0.08f)) 33 | ) 34 | Box( 35 | modifier = Modifier 36 | .padding(8.dp) 37 | .background(MaterialTheme.colors.onBackground.copy(alpha = 0.08f)) 38 | ) 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/home/PodcastView.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.home 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.unit.dp 13 | import com.fabirt.podcastapp.domain.model.Episode 14 | import com.fabirt.podcastapp.ui.podcast.PodcastImage 15 | 16 | @Composable 17 | fun PodcastView( 18 | podcast: Episode, 19 | modifier: Modifier = Modifier, 20 | onClick: () -> Unit 21 | ) { 22 | Column( 23 | modifier = modifier 24 | .clip(MaterialTheme.shapes.medium) 25 | .background(MaterialTheme.colors.background) 26 | .clickable(onClick = onClick), 27 | ) { 28 | PodcastImage( 29 | url = podcast.thumbnail, 30 | aspectRatio = 1f 31 | ) 32 | Text( 33 | podcast.titleOriginal, 34 | style = MaterialTheme.typography.body1, 35 | modifier = Modifier.padding(8.dp) 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/navigation/Destination.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.navigation 2 | 3 | object Destination { 4 | const val welcome = "welcome" 5 | const val home = "home" 6 | const val podcast = "podcast/{id}" 7 | 8 | fun podcast(id: String): String = "podcast/$id" 9 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/navigation/Navigation.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.navigation 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | import androidx.navigation.NavHostController 7 | import androidx.navigation.compose.rememberNavController 8 | 9 | object Navigator { 10 | val current: NavHostController 11 | @Composable 12 | get() = LocalNavHostController.current 13 | } 14 | 15 | @Composable 16 | fun ProvideNavHostController(content: @Composable () -> Unit) { 17 | val navController = rememberNavController() 18 | CompositionLocalProvider( 19 | LocalNavHostController provides navController, 20 | content = content 21 | ) 22 | } 23 | 24 | private val LocalNavHostController = staticCompositionLocalOf { 25 | error("No NavHostController provided") 26 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/podcast/PodcastBottomBar.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.podcast 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.ExperimentalAnimationApi 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.gestures.Orientation 10 | import androidx.compose.foundation.gestures.detectTapGestures 11 | import androidx.compose.foundation.isSystemInDarkTheme 12 | import androidx.compose.foundation.layout.* 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.material.* 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.geometry.Offset 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.graphics.graphicsLayer 23 | import androidx.compose.ui.input.pointer.pointerInput 24 | import androidx.compose.ui.layout.ContentScale 25 | import androidx.compose.ui.platform.LocalConfiguration 26 | import androidx.compose.ui.platform.LocalDensity 27 | import androidx.compose.ui.res.painterResource 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.text.style.TextOverflow 30 | import androidx.compose.ui.tooling.preview.Preview 31 | import androidx.compose.ui.unit.IntOffset 32 | import androidx.compose.ui.unit.dp 33 | import com.fabirt.podcastapp.R 34 | import com.fabirt.podcastapp.domain.model.Episode 35 | import com.fabirt.podcastapp.domain.model.Podcast 36 | import com.fabirt.podcastapp.ui.common.PreviewContent 37 | import com.fabirt.podcastapp.ui.common.ViewModelProvider 38 | import com.google.accompanist.coil.rememberCoilPainter 39 | import com.google.accompanist.insets.navigationBarsPadding 40 | import kotlin.math.roundToInt 41 | 42 | @OptIn(ExperimentalAnimationApi::class) 43 | @Composable 44 | fun PodcastBottomBar( 45 | modifier: Modifier = Modifier 46 | ) { 47 | val episode = ViewModelProvider.podcastPlayer.currentPlayingEpisode.value 48 | 49 | AnimatedVisibility( 50 | visible = episode != null, 51 | modifier = modifier 52 | ) { 53 | if (episode != null) { 54 | PodcastBottomBarContent(episode) 55 | } 56 | } 57 | } 58 | 59 | @OptIn(ExperimentalMaterialApi::class) 60 | @Composable 61 | fun PodcastBottomBarContent(episode: Episode) { 62 | val swipeableState = rememberSwipeableState(0) 63 | val podcastPlayer = ViewModelProvider.podcastPlayer 64 | 65 | val endAnchor = LocalConfiguration.current.screenWidthDp * LocalDensity.current.density 66 | val anchors = mapOf( 67 | 0f to 0, 68 | endAnchor to 1 69 | ) 70 | 71 | val iconResId = 72 | if (podcastPlayer.podcastIsPlaying) R.drawable.ic_round_pause else R.drawable.ic_round_play_arrow 73 | 74 | Box( 75 | modifier = Modifier 76 | .fillMaxWidth() 77 | .swipeable( 78 | state = swipeableState, 79 | anchors = anchors, 80 | thresholds = { _, _ -> FractionalThreshold(0.54f) }, 81 | orientation = Orientation.Horizontal 82 | ) 83 | ) { 84 | if (swipeableState.currentValue >= 1) { 85 | LaunchedEffect("key") { 86 | podcastPlayer.stopPlayback() 87 | } 88 | } 89 | 90 | PodcastBottomBarStatelessContent( 91 | episode = episode, 92 | xOffset = swipeableState.offset.value.roundToInt(), 93 | darkTheme = isSystemInDarkTheme(), 94 | icon = iconResId, 95 | onTooglePlaybackState = { 96 | podcastPlayer.tooglePlaybackState() 97 | } 98 | ) { 99 | podcastPlayer.showPlayerFullScreen = true 100 | } 101 | } 102 | } 103 | 104 | @Composable 105 | fun PodcastBottomBarStatelessContent( 106 | episode: Episode, 107 | xOffset: Int, 108 | darkTheme: Boolean, 109 | @DrawableRes icon: Int, 110 | onTooglePlaybackState: () -> Unit, 111 | onTap: (Offset) -> Unit, 112 | ) { 113 | Box( 114 | modifier = Modifier 115 | .offset { IntOffset(xOffset, 0) } 116 | .background(if (darkTheme) Color(0xFF343434) else Color(0xFFF1F1F1)) 117 | .navigationBarsPadding() 118 | .height(64.dp) 119 | .fillMaxWidth() 120 | .pointerInput(Unit) { 121 | detectTapGestures( 122 | onTap = onTap 123 | ) 124 | } 125 | ) { 126 | Row( 127 | verticalAlignment = Alignment.CenterVertically 128 | ) { 129 | Image( 130 | painter = rememberCoilPainter(episode.thumbnail), 131 | contentDescription = stringResource(R.string.podcast_thumbnail), 132 | contentScale = ContentScale.Crop, 133 | modifier = Modifier.size(64.dp), 134 | ) 135 | 136 | Column( 137 | verticalArrangement = Arrangement.Center, 138 | modifier = Modifier 139 | .weight(1f) 140 | .fillMaxHeight() 141 | .padding(8.dp), 142 | ) { 143 | Text( 144 | episode.titleOriginal, 145 | style = MaterialTheme.typography.body2, 146 | color = MaterialTheme.colors.onBackground, 147 | maxLines = 1, 148 | overflow = TextOverflow.Ellipsis, 149 | ) 150 | 151 | Text( 152 | episode.podcast.titleOriginal, 153 | style = MaterialTheme.typography.body2, 154 | color = MaterialTheme.colors.onBackground, 155 | maxLines = 1, 156 | overflow = TextOverflow.Ellipsis, 157 | modifier = Modifier.graphicsLayer { 158 | alpha = 0.60f 159 | } 160 | ) 161 | } 162 | 163 | Icon( 164 | painter = painterResource(icon), 165 | contentDescription = stringResource(R.string.play), 166 | tint = MaterialTheme.colors.onBackground, 167 | modifier = Modifier 168 | .padding(end = 8.dp) 169 | .size(40.dp) 170 | .clip(CircleShape) 171 | .clickable(onClick = onTooglePlaybackState) 172 | .padding(6.dp) 173 | ) 174 | } 175 | } 176 | } 177 | 178 | @Preview(name = "Bottom Bar") 179 | @Composable 180 | fun PodcastBottomBarPreview() { 181 | PreviewContent(darkTheme = true) { 182 | PodcastBottomBarStatelessContent( 183 | episode = Episode( 184 | "1", 185 | "", 186 | "", 187 | "https://picsum.photos/200", 188 | Podcast("", "", "", "This is podcast title", "", "This is publisher"), 189 | "https://picsum.photos/200", 190 | 0, 191 | "This is a title", 192 | "", 193 | 2700, 194 | false, 195 | "This is a description" 196 | ), 197 | xOffset = 0, 198 | darkTheme = true, 199 | icon = R.drawable.ic_round_play_arrow, 200 | onTooglePlaybackState = { }, 201 | onTap = { } 202 | ) 203 | } 204 | } 205 | 206 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/podcast/PodcastDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.podcast 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.foundation.verticalScroll 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.rounded.Info 9 | import androidx.compose.material.icons.rounded.Share 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.unit.dp 15 | import com.fabirt.podcastapp.R 16 | import com.fabirt.podcastapp.ui.common.BackButton 17 | import com.fabirt.podcastapp.ui.common.EmphasisText 18 | import com.fabirt.podcastapp.ui.common.PrimaryButton 19 | import com.fabirt.podcastapp.ui.common.ViewModelProvider 20 | import com.fabirt.podcastapp.util.Resource 21 | import com.fabirt.podcastapp.util.formatMillisecondsAsDate 22 | import com.fabirt.podcastapp.util.toDurationMinutes 23 | import com.google.accompanist.insets.navigationBarsPadding 24 | import com.google.accompanist.insets.statusBarsPadding 25 | 26 | @Composable 27 | fun PodcastDetailScreen( 28 | podcastId: String, 29 | ) { 30 | val scrollState = rememberScrollState() 31 | val podcastSearchViewModel = ViewModelProvider.podcastSearch 32 | val detailViewModel = ViewModelProvider.podcastDetail 33 | val playerViewModel = ViewModelProvider.podcastPlayer 34 | val podcast = podcastSearchViewModel.getPodcastDetail(podcastId) 35 | val currentContext = LocalContext.current 36 | 37 | Surface { 38 | Column( 39 | modifier = Modifier 40 | .statusBarsPadding() 41 | ) { 42 | Row { 43 | BackButton() 44 | } 45 | 46 | if (podcast != null) { 47 | val playButtonText = 48 | if (playerViewModel.podcastIsPlaying && 49 | playerViewModel.currentPlayingEpisode.value?.id == podcast.id 50 | ) stringResource(R.string.pause) else stringResource(R.string.play) 51 | 52 | Column( 53 | modifier = Modifier 54 | .verticalScroll(scrollState) 55 | .navigationBarsPadding() 56 | .padding(vertical = 24.dp, horizontal = 16.dp) 57 | .padding(bottom = if (playerViewModel.currentPlayingEpisode.value != null) 64.dp else 0.dp) 58 | 59 | ) { 60 | PodcastImage( 61 | url = podcast.image, 62 | modifier = Modifier.height(120.dp) 63 | ) 64 | 65 | Spacer(modifier = Modifier.height(32.dp)) 66 | 67 | Text( 68 | podcast.titleOriginal, 69 | style = MaterialTheme.typography.h1 70 | ) 71 | Spacer(modifier = Modifier.height(24.dp)) 72 | 73 | Text( 74 | podcast.podcast.publisherOriginal, 75 | style = MaterialTheme.typography.body1 76 | ) 77 | 78 | EmphasisText( 79 | text = "${podcast.pubDateMS.formatMillisecondsAsDate("MMM dd")} • ${podcast.audioLengthSec.toDurationMinutes()}" 80 | ) 81 | 82 | Spacer(modifier = Modifier.height(16.dp)) 83 | 84 | Row { 85 | PrimaryButton( 86 | text = playButtonText, 87 | height = 48.dp 88 | ) { 89 | playerViewModel.playPodcast( 90 | (podcastSearchViewModel.podcastSearch as Resource.Success).data.results, 91 | podcast 92 | ) 93 | } 94 | 95 | Spacer(modifier = Modifier.weight(1f)) 96 | 97 | com.fabirt.podcastapp.ui.common.IconButton( 98 | imageVector = Icons.Rounded.Share, 99 | contentDescription = stringResource(R.string.share) 100 | ) { 101 | detailViewModel.sharePodcastEpidose(currentContext, podcast) 102 | } 103 | 104 | com.fabirt.podcastapp.ui.common.IconButton( 105 | imageVector = Icons.Rounded.Info, 106 | contentDescription = stringResource(R.string.source_web) 107 | ) { 108 | detailViewModel.openListenNotesURL(currentContext, podcast) 109 | } 110 | } 111 | 112 | Spacer(modifier = Modifier.height(16.dp)) 113 | 114 | EmphasisText(text = podcast.descriptionOriginal) 115 | } 116 | } 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/podcast/PodcastImage.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.podcast 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.aspectRatio 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.material.Icon 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.clip 15 | import androidx.compose.ui.layout.ContentScale 16 | import androidx.compose.ui.res.painterResource 17 | import androidx.compose.ui.res.stringResource 18 | import androidx.compose.ui.unit.dp 19 | import com.fabirt.podcastapp.R 20 | import com.google.accompanist.coil.rememberCoilPainter 21 | import com.google.accompanist.imageloading.ImageLoadState 22 | 23 | @Composable 24 | fun PodcastImage( 25 | url: String, 26 | modifier: Modifier = Modifier, 27 | aspectRatio: Float = 1f, 28 | ) { 29 | val imagePainter = rememberCoilPainter(url) 30 | 31 | Box( 32 | modifier 33 | .clip(MaterialTheme.shapes.medium) 34 | .aspectRatio(aspectRatio) 35 | .background(MaterialTheme.colors.onBackground.copy(alpha = 0.08f)) 36 | ) { 37 | Image( 38 | painter = imagePainter, 39 | contentDescription = stringResource(R.string.podcast_thumbnail), 40 | contentScale = ContentScale.Crop, 41 | modifier = Modifier.fillMaxSize(), 42 | ) 43 | when (imagePainter.loadState) { 44 | is ImageLoadState.Success -> { 45 | // Remove placeholder 46 | } 47 | else -> { 48 | Icon( 49 | painter = painterResource(R.drawable.ic_microphone), 50 | contentDescription = stringResource(R.string.podcast_thumbnail), 51 | tint = MaterialTheme.colors.onBackground.copy(alpha = 0.14f), 52 | modifier = Modifier 53 | .size(48.dp) 54 | .align(Alignment.Center), 55 | ) 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/podcast/PodcastPlayerScreen.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.podcast 2 | 3 | import androidx.activity.OnBackPressedCallback 4 | import androidx.activity.OnBackPressedDispatcher 5 | import androidx.annotation.DrawableRes 6 | import androidx.compose.animation.* 7 | import androidx.compose.foundation.Image 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.gestures.Orientation 11 | import androidx.compose.foundation.isSystemInDarkTheme 12 | import androidx.compose.foundation.layout.* 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.material.* 15 | import androidx.compose.material.ProgressIndicatorDefaults.IndicatorBackgroundOpacity 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.rounded.KeyboardArrowDown 18 | import androidx.compose.runtime.* 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.graphics.Brush 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.graphics.graphicsLayer 25 | import androidx.compose.ui.graphics.painter.Painter 26 | import androidx.compose.ui.layout.ContentScale 27 | import androidx.compose.ui.platform.LocalConfiguration 28 | import androidx.compose.ui.platform.LocalContext 29 | import androidx.compose.ui.platform.LocalDensity 30 | import androidx.compose.ui.res.painterResource 31 | import androidx.compose.ui.res.stringResource 32 | import androidx.compose.ui.text.style.TextOverflow 33 | import androidx.compose.ui.tooling.preview.Preview 34 | import androidx.compose.ui.unit.IntOffset 35 | import androidx.compose.ui.unit.dp 36 | import coil.request.ImageRequest 37 | import com.fabirt.podcastapp.R 38 | import com.fabirt.podcastapp.domain.model.Episode 39 | import com.fabirt.podcastapp.domain.model.Podcast 40 | import com.fabirt.podcastapp.ui.common.EmphasisText 41 | import com.fabirt.podcastapp.ui.common.IconButton 42 | import com.fabirt.podcastapp.ui.common.PreviewContent 43 | import com.fabirt.podcastapp.ui.common.ViewModelProvider 44 | import com.google.accompanist.coil.rememberCoilPainter 45 | import com.google.accompanist.insets.systemBarsPadding 46 | import kotlin.math.roundToInt 47 | 48 | @OptIn(ExperimentalAnimationApi::class) 49 | @Composable 50 | fun PodcastPlayerScreen(backDispatcher: OnBackPressedDispatcher) { 51 | val podcastPlayer = ViewModelProvider.podcastPlayer 52 | val episode = podcastPlayer.currentPlayingEpisode.value 53 | 54 | AnimatedVisibility( 55 | visible = episode != null && podcastPlayer.showPlayerFullScreen, 56 | enter = slideInVertically( 57 | initialOffsetY = { it } 58 | ), 59 | exit = slideOutVertically( 60 | targetOffsetY = { it } 61 | ) 62 | ) { 63 | if (episode != null) { 64 | PodcastPlayerBody(episode, backDispatcher) 65 | } 66 | } 67 | } 68 | 69 | @OptIn(ExperimentalMaterialApi::class) 70 | @Composable 71 | fun PodcastPlayerBody(episode: Episode, backDispatcher: OnBackPressedDispatcher) { 72 | val podcastPlayer = ViewModelProvider.podcastPlayer 73 | val swipeableState = rememberSwipeableState(0) 74 | val endAnchor = LocalConfiguration.current.screenHeightDp * LocalDensity.current.density 75 | val anchors = mapOf( 76 | 0f to 0, 77 | endAnchor to 1 78 | ) 79 | 80 | val backCallback = remember { 81 | object : OnBackPressedCallback(true) { 82 | override fun handleOnBackPressed() { 83 | podcastPlayer.showPlayerFullScreen = false 84 | } 85 | } 86 | } 87 | 88 | val backgroundColor = MaterialTheme.colors.background 89 | var gradientColor by remember { 90 | mutableStateOf(backgroundColor) 91 | } 92 | 93 | val imageRequest = ImageRequest.Builder(LocalContext.current) 94 | .data(episode.image) 95 | .target { 96 | podcastPlayer.calculateColorPalette(it) { color -> 97 | gradientColor = color 98 | } 99 | } 100 | .build() 101 | 102 | val imagePainter = rememberCoilPainter(request = imageRequest) 103 | 104 | val iconResId = 105 | if (podcastPlayer.podcastIsPlaying) R.drawable.ic_round_pause else R.drawable.ic_round_play_arrow 106 | 107 | var sliderIsChanging by remember { mutableStateOf(false) } 108 | 109 | var localSliderValue by remember { mutableStateOf(0f) } 110 | 111 | val sliderProgress = if (sliderIsChanging) localSliderValue else podcastPlayer.currentEpisodeProgress 112 | 113 | Box( 114 | modifier = Modifier 115 | .fillMaxSize() 116 | .swipeable( 117 | state = swipeableState, 118 | anchors = anchors, 119 | thresholds = { _, _ -> FractionalThreshold(0.34f) }, 120 | orientation = Orientation.Vertical 121 | ) 122 | ) { 123 | if (swipeableState.currentValue >= 1) { 124 | LaunchedEffect("key") { 125 | podcastPlayer.showPlayerFullScreen = false 126 | } 127 | } 128 | 129 | PodcastPlayerSatelessContent( 130 | episode = episode, 131 | darkTheme = isSystemInDarkTheme(), 132 | imagePainter = imagePainter, 133 | gradientColor = gradientColor, 134 | yOffset = swipeableState.offset.value.roundToInt(), 135 | playPauseIcon = iconResId, 136 | playbackProgress = sliderProgress, 137 | currentTime = podcastPlayer.currentPlaybackFormattedPosition, 138 | totalTime = podcastPlayer.currentEpisodeFormattedDuration, 139 | onRewind = { 140 | podcastPlayer.rewind() 141 | }, 142 | onForward = { 143 | podcastPlayer.fastForward() 144 | }, 145 | onTooglePlayback = { 146 | podcastPlayer.tooglePlaybackState() 147 | }, 148 | onSliderChange = { newPosition -> 149 | localSliderValue = newPosition 150 | sliderIsChanging = true 151 | }, 152 | onSliderChangeFinished = { 153 | podcastPlayer.seekToFraction(localSliderValue) 154 | sliderIsChanging = false 155 | } 156 | ) { 157 | podcastPlayer.showPlayerFullScreen = false 158 | } 159 | } 160 | 161 | LaunchedEffect("playbackPosition") { 162 | podcastPlayer.updateCurrentPlaybackPosition() 163 | } 164 | 165 | DisposableEffect(backDispatcher) { 166 | backDispatcher.addCallback(backCallback) 167 | 168 | onDispose { 169 | backCallback.remove() 170 | podcastPlayer.showPlayerFullScreen = false 171 | } 172 | } 173 | } 174 | 175 | @Composable 176 | fun PodcastPlayerSatelessContent( 177 | episode: Episode, 178 | imagePainter: Painter, 179 | gradientColor: Color, 180 | yOffset: Int, 181 | @DrawableRes playPauseIcon: Int, 182 | playbackProgress: Float, 183 | currentTime: String, 184 | totalTime: String, 185 | darkTheme: Boolean, 186 | onRewind: () -> Unit, 187 | onForward: () -> Unit, 188 | onTooglePlayback: () -> Unit, 189 | onSliderChange: (Float) -> Unit, 190 | onSliderChangeFinished: () -> Unit, 191 | onClose: () -> Unit 192 | ) { 193 | val gradientColors = if (darkTheme) { 194 | listOf(gradientColor, MaterialTheme.colors.background) 195 | } else { 196 | listOf(MaterialTheme.colors.background, MaterialTheme.colors.background) 197 | } 198 | 199 | val sliderColors = if (darkTheme) { 200 | SliderDefaults.colors( 201 | thumbColor = MaterialTheme.colors.onBackground, 202 | activeTrackColor = MaterialTheme.colors.onBackground, 203 | inactiveTrackColor = MaterialTheme.colors.onBackground.copy( 204 | alpha = IndicatorBackgroundOpacity 205 | ), 206 | ) 207 | } else SliderDefaults.colors( 208 | thumbColor = gradientColor, 209 | activeTrackColor = gradientColor, 210 | inactiveTrackColor = gradientColor.copy( 211 | alpha = IndicatorBackgroundOpacity 212 | ), 213 | ) 214 | 215 | 216 | Box( 217 | modifier = Modifier 218 | .offset { IntOffset(0, yOffset) } 219 | .fillMaxSize() 220 | ) { 221 | Surface { 222 | Box( 223 | modifier = Modifier 224 | .background( 225 | Brush.verticalGradient( 226 | colors = gradientColors, 227 | endY = LocalConfiguration.current.screenHeightDp.toFloat() * LocalDensity.current.density / 2 228 | ) 229 | ) 230 | .fillMaxSize() 231 | .systemBarsPadding() 232 | ) { 233 | Column { 234 | IconButton( 235 | imageVector = Icons.Rounded.KeyboardArrowDown, 236 | contentDescription = stringResource(R.string.close), 237 | onClick = onClose 238 | ) 239 | 240 | Column( 241 | modifier = Modifier.padding(horizontal = 24.dp) 242 | ) { 243 | 244 | Box( 245 | modifier = Modifier 246 | .padding(vertical = 32.dp) 247 | .clip(MaterialTheme.shapes.medium) 248 | .weight(1f, fill = false) 249 | .aspectRatio(1f) 250 | .background(MaterialTheme.colors.onBackground.copy(alpha = 0.08f)) 251 | ) { 252 | Image( 253 | painter = imagePainter, 254 | contentDescription = stringResource(R.string.podcast_thumbnail), 255 | contentScale = ContentScale.Crop, 256 | modifier = Modifier.fillMaxSize(), 257 | ) 258 | } 259 | 260 | Text( 261 | episode.titleOriginal, 262 | style = MaterialTheme.typography.h5, 263 | color = MaterialTheme.colors.onBackground, 264 | maxLines = 1, 265 | overflow = TextOverflow.Ellipsis, 266 | ) 267 | 268 | Text( 269 | episode.podcast.titleOriginal, 270 | style = MaterialTheme.typography.subtitle1, 271 | color = MaterialTheme.colors.onBackground, 272 | maxLines = 1, 273 | overflow = TextOverflow.Ellipsis, 274 | modifier = Modifier.graphicsLayer { 275 | alpha = 0.60f 276 | } 277 | ) 278 | 279 | Column( 280 | modifier = Modifier 281 | .fillMaxWidth() 282 | .padding(vertical = 24.dp) 283 | ) { 284 | Slider( 285 | value = playbackProgress, 286 | modifier = Modifier 287 | .fillMaxWidth(), 288 | colors = sliderColors, 289 | onValueChange = onSliderChange, 290 | onValueChangeFinished = onSliderChangeFinished, 291 | ) 292 | 293 | Row( 294 | modifier = Modifier.fillMaxWidth(), 295 | horizontalArrangement = Arrangement.SpaceBetween, 296 | verticalAlignment = Alignment.CenterVertically 297 | ) { 298 | EmphasisText(text = currentTime) 299 | EmphasisText(text = totalTime) 300 | } 301 | } 302 | 303 | Row( 304 | horizontalArrangement = Arrangement.SpaceAround, 305 | verticalAlignment = Alignment.CenterVertically, 306 | modifier = Modifier 307 | .fillMaxWidth() 308 | .padding(vertical = 8.dp), 309 | ) { 310 | Icon( 311 | painter = painterResource(R.drawable.ic_round_replay_10), 312 | contentDescription = stringResource(R.string.replay_10_seconds), 313 | modifier = Modifier 314 | .clip(CircleShape) 315 | .clickable(onClick = onRewind) 316 | .padding(12.dp) 317 | .size(32.dp) 318 | ) 319 | Icon( 320 | painter = painterResource(playPauseIcon), 321 | contentDescription = stringResource(R.string.play), 322 | tint = MaterialTheme.colors.background, 323 | modifier = Modifier 324 | .clip(CircleShape) 325 | .background(MaterialTheme.colors.onBackground) 326 | .clickable(onClick = onTooglePlayback) 327 | .size(64.dp) 328 | .padding(8.dp) 329 | ) 330 | Icon( 331 | painter = painterResource(R.drawable.ic_round_forward_10), 332 | contentDescription = stringResource(R.string.forward_10_seconds), 333 | modifier = Modifier 334 | .clip(CircleShape) 335 | .clickable(onClick = onForward) 336 | .padding(12.dp) 337 | .size(32.dp) 338 | ) 339 | } 340 | } 341 | } 342 | } 343 | } 344 | } 345 | } 346 | 347 | @Preview(name = "Player") 348 | @Composable 349 | fun PodcastPlayerPreview() { 350 | PreviewContent(darkTheme = true) { 351 | PodcastPlayerSatelessContent( 352 | episode = Episode( 353 | "1", 354 | "", 355 | "", 356 | "", 357 | Podcast("", "", "", "This is podcast title", "", "This is publisher"), 358 | "", 359 | 0, 360 | "This is a title", 361 | "", 362 | 2700, 363 | false, 364 | "This is a description" 365 | ), 366 | imagePainter = painterResource(id = R.drawable.ic_microphone), 367 | gradientColor = Color.DarkGray, 368 | yOffset = 0, 369 | playPauseIcon = R.drawable.ic_round_play_arrow, 370 | playbackProgress = 0f, 371 | currentTime = "0:00", 372 | totalTime = "10:00", 373 | darkTheme = true, 374 | onClose = { }, 375 | onForward = { }, 376 | onRewind = { }, 377 | onTooglePlayback = { }, 378 | onSliderChange = { }, 379 | onSliderChangeFinished = { } 380 | ) 381 | } 382 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) 9 | val Orange700 = Color(0xFFF45B49) -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(12.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val DarkColorPalette = darkColors( 11 | primary = Orange700, 12 | secondary = Teal200, 13 | background = Color.Black, 14 | surface = Color.Black, 15 | onPrimary = Color.White, 16 | onBackground = Color.White, 17 | onSurface = Color.White, 18 | ) 19 | 20 | private val LightColorPalette = lightColors( 21 | primary = Orange700, 22 | secondary = Teal200, 23 | background = Color.White, 24 | surface = Color.White, 25 | onPrimary = Color.White, 26 | onBackground = Color.Black, 27 | onSurface = Color.Black, 28 | ) 29 | 30 | @Composable 31 | fun PodcastAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 32 | val colors = if (darkTheme) { 33 | DarkColorPalette 34 | } else { 35 | LightColorPalette 36 | } 37 | 38 | MaterialTheme( 39 | colors = colors, 40 | typography = Typography, 41 | shapes = Shapes, 42 | content = content 43 | ) 44 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.Font 6 | import androidx.compose.ui.text.font.FontFamily 7 | import androidx.compose.ui.text.font.FontWeight 8 | import androidx.compose.ui.unit.sp 9 | import com.fabirt.podcastapp.R 10 | 11 | val NunitoSans = FontFamily( 12 | Font(R.font.nunito_sans_regular), 13 | Font(R.font.nunito_sans_light, weight = FontWeight.Light), 14 | Font(R.font.nunito_sans_semibold, weight = FontWeight.SemiBold), 15 | Font(R.font.nunito_sans_bold, weight = FontWeight.Bold), 16 | ) 17 | 18 | val Typography = Typography( 19 | h1 = TextStyle( 20 | fontFamily = NunitoSans, 21 | fontWeight = FontWeight.Bold, 22 | fontSize = 34.sp 23 | ), 24 | h2 = TextStyle( 25 | fontFamily = NunitoSans, 26 | fontWeight = FontWeight.Bold, 27 | fontSize = 14.sp, 28 | ), 29 | h5 = TextStyle( 30 | fontFamily = NunitoSans, 31 | fontWeight = FontWeight.Bold, 32 | fontSize = 18.sp, 33 | ), 34 | subtitle1 = TextStyle( 35 | fontFamily = NunitoSans, 36 | fontWeight = FontWeight.Bold, 37 | fontSize = 16.sp, 38 | ), 39 | body1 = TextStyle( 40 | fontFamily = NunitoSans, 41 | fontWeight = FontWeight.SemiBold, 42 | fontSize = 14.sp, 43 | ), 44 | body2 = TextStyle( 45 | fontFamily = NunitoSans, 46 | fontWeight = FontWeight.Normal, 47 | fontSize = 12.sp, 48 | ), 49 | button = TextStyle( 50 | fontFamily = NunitoSans, 51 | fontWeight = FontWeight.Bold, 52 | fontSize = 18.sp, 53 | ), 54 | caption = TextStyle( 55 | fontFamily = NunitoSans, 56 | fontWeight = FontWeight.SemiBold, 57 | fontSize = 12.sp, 58 | ), 59 | ) -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/viewmodel/PodcastDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.viewmodel 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.net.Uri 6 | import androidx.lifecycle.ViewModel 7 | import com.fabirt.podcastapp.R 8 | import com.fabirt.podcastapp.domain.model.Episode 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class PodcastDetailViewModel @Inject constructor() : ViewModel() { 14 | 15 | fun sharePodcastEpidose(context: Context, episode: Episode) { 16 | val text = context.getString( 17 | R.string.share_podcast_content, 18 | episode.titleOriginal, 19 | episode.listennotesURL 20 | ) 21 | val sendIntent: Intent = Intent().apply { 22 | action = Intent.ACTION_SEND 23 | putExtra(Intent.EXTRA_TITLE, episode.titleOriginal) 24 | putExtra(Intent.EXTRA_TEXT, text) 25 | type = "text/plain" 26 | } 27 | 28 | val shareIntent = Intent.createChooser(sendIntent, null) 29 | context.startActivity(shareIntent) 30 | } 31 | 32 | fun openListenNotesURL(context: Context, episode: Episode) { 33 | val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(episode.link)) 34 | context.startActivity(webIntent) 35 | } 36 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/viewmodel/PodcastPlayerViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.viewmodel 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.drawable.BitmapDrawable 5 | import android.graphics.drawable.Drawable 6 | import android.support.v4.media.MediaBrowserCompat 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.setValue 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.lifecycle.ViewModel 12 | import androidx.palette.graphics.Palette 13 | import com.fabirt.podcastapp.constant.K 14 | import com.fabirt.podcastapp.data.service.MediaPlayerService 15 | import com.fabirt.podcastapp.data.service.MediaPlayerServiceConnection 16 | import com.fabirt.podcastapp.domain.model.Episode 17 | import com.fabirt.podcastapp.util.currentPosition 18 | import com.fabirt.podcastapp.util.isPlayEnabled 19 | import com.fabirt.podcastapp.util.isPlaying 20 | import dagger.hilt.android.lifecycle.HiltViewModel 21 | import kotlinx.coroutines.delay 22 | import java.text.SimpleDateFormat 23 | import java.util.* 24 | import javax.inject.Inject 25 | 26 | @HiltViewModel 27 | class PodcastPlayerViewModel @Inject constructor( 28 | private val serviceConnection: MediaPlayerServiceConnection 29 | ) : ViewModel() { 30 | 31 | val currentPlayingEpisode = serviceConnection.currentPlayingEpisode 32 | 33 | var showPlayerFullScreen by mutableStateOf(false) 34 | 35 | var currentPlaybackPosition by mutableStateOf(0L) 36 | 37 | val podcastIsPlaying: Boolean 38 | get() = playbackState.value?.isPlaying == true 39 | 40 | val currentEpisodeProgress: Float 41 | get() { 42 | if (currentEpisodeDuration > 0) { 43 | return currentPlaybackPosition.toFloat() / currentEpisodeDuration 44 | } 45 | return 0f 46 | } 47 | 48 | val currentPlaybackFormattedPosition: String 49 | get() = formatLong(currentPlaybackPosition) 50 | 51 | val currentEpisodeFormattedDuration: String 52 | get() = formatLong(currentEpisodeDuration) 53 | 54 | private val playbackState = serviceConnection.playbackState 55 | 56 | private val currentEpisodeDuration: Long 57 | get() = MediaPlayerService.currentDuration 58 | 59 | fun playPodcast(episodes: List, currentEpisode: Episode) { 60 | serviceConnection.playPodcast(episodes) 61 | if (currentEpisode.id == currentPlayingEpisode.value?.id) { 62 | if (podcastIsPlaying) { 63 | serviceConnection.transportControls.pause() 64 | } else { 65 | serviceConnection.transportControls.play() 66 | } 67 | } else { 68 | serviceConnection.transportControls.playFromMediaId(currentEpisode.id, null) 69 | } 70 | } 71 | 72 | fun tooglePlaybackState() { 73 | when { 74 | playbackState.value?.isPlaying == true -> { 75 | serviceConnection.transportControls.pause() 76 | } 77 | 78 | playbackState.value?.isPlayEnabled == true -> { 79 | serviceConnection.transportControls.play() 80 | } 81 | } 82 | } 83 | 84 | fun stopPlayback() { 85 | serviceConnection.transportControls.stop() 86 | } 87 | 88 | fun calculateColorPalette(drawable: Drawable, onFinised: (Color) -> Unit) { 89 | val bitmap = (drawable as BitmapDrawable).bitmap.copy(Bitmap.Config.ARGB_8888, true) 90 | Palette.from(bitmap).generate { palette -> 91 | palette?.darkVibrantSwatch?.rgb?.let { colorValue -> 92 | onFinised(Color(colorValue)) 93 | } 94 | } 95 | } 96 | 97 | fun fastForward() { 98 | serviceConnection.fastForward() 99 | } 100 | 101 | fun rewind() { 102 | serviceConnection.rewind() 103 | } 104 | 105 | /** 106 | * @param value 0.0 to 1.0 107 | */ 108 | fun seekToFraction(value: Float) { 109 | serviceConnection.transportControls.seekTo( 110 | (currentEpisodeDuration * value).toLong() 111 | ) 112 | } 113 | 114 | suspend fun updateCurrentPlaybackPosition() { 115 | val currentPosition = playbackState.value?.currentPosition 116 | if (currentPosition != null && currentPosition != currentPlaybackPosition) { 117 | currentPlaybackPosition = currentPosition 118 | } 119 | delay(K.PLAYBACK_POSITION_UPDATE_INTERVAL) 120 | updateCurrentPlaybackPosition() 121 | } 122 | 123 | private fun formatLong(value: Long): String { 124 | val dateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) 125 | return dateFormat.format(value) 126 | } 127 | 128 | override fun onCleared() { 129 | super.onCleared() 130 | serviceConnection.unsubscribe( 131 | K.MEDIA_ROOT_ID, 132 | object : MediaBrowserCompat.SubscriptionCallback() {}) 133 | } 134 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/viewmodel/PodcastSearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.viewmodel 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.fabirt.podcastapp.domain.model.Episode 9 | import com.fabirt.podcastapp.domain.model.PodcastSearch 10 | import com.fabirt.podcastapp.domain.repository.PodcastRepository 11 | import com.fabirt.podcastapp.util.Resource 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.launch 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class PodcastSearchViewModel @Inject constructor( 18 | private val repository: PodcastRepository 19 | ) : ViewModel() { 20 | 21 | var podcastSearch by mutableStateOf>(Resource.Loading) 22 | private set 23 | 24 | init { 25 | searchPodcasts() 26 | } 27 | 28 | fun getPodcastDetail(id: String): Episode? { 29 | return when (podcastSearch) { 30 | is Resource.Error -> null 31 | Resource.Loading -> null 32 | is Resource.Success -> (podcastSearch as Resource.Success).data.results.find { it.id == id } 33 | } 34 | } 35 | 36 | fun searchPodcasts() { 37 | viewModelScope.launch { 38 | podcastSearch = Resource.Loading 39 | val result = repository.searchPodcasts("fiction", "episode") 40 | result.fold( 41 | { failure -> 42 | podcastSearch = Resource.Error(failure) 43 | }, 44 | { data -> 45 | podcastSearch = Resource.Success(data) 46 | } 47 | ) 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/welcome/AnimatedButton.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.welcome 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.animation.fadeIn 7 | import androidx.compose.animation.slideInVertically 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.res.stringResource 10 | import com.fabirt.podcastapp.R 11 | import com.fabirt.podcastapp.ui.common.PrimaryButton 12 | 13 | @OptIn(ExperimentalAnimationApi::class) 14 | @Composable 15 | fun AnimatedButton(visible: Boolean, onClick: () -> Unit) { 16 | val buttonEnterTransition = fadeIn( 17 | animationSpec = tween(1000, 2600) 18 | ) + slideInVertically( 19 | initialOffsetY = { 100 }, 20 | animationSpec = tween(1000, 2600) 21 | ) 22 | 23 | AnimatedVisibility( 24 | visible = visible, 25 | enter = buttonEnterTransition, 26 | ) { 27 | PrimaryButton( 28 | text = stringResource(R.string.get_started), 29 | onClick = onClick 30 | ) 31 | } 32 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/welcome/AnimatedImage.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.welcome 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.animation.fadeIn 7 | import androidx.compose.foundation.Image 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.painterResource 13 | import com.fabirt.podcastapp.R 14 | 15 | @OptIn(ExperimentalAnimationApi::class) 16 | @Composable 17 | fun AnimatedImage(visible: Boolean) { 18 | val imageEnterTransition = fadeIn( 19 | animationSpec = tween(2000) 20 | ) 21 | 22 | AnimatedVisibility( 23 | visible = visible, 24 | enter = imageEnterTransition, 25 | ) { 26 | Image( 27 | painter = painterResource(R.drawable.ic_music_file), 28 | contentDescription = "", 29 | modifier = Modifier 30 | .fillMaxHeight(0.6f) 31 | .fillMaxWidth() 32 | ) 33 | } 34 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/welcome/AnimatedTitle.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.welcome 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.animation.fadeIn 7 | import androidx.compose.animation.slideInVertically 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.text.style.TextAlign 13 | import com.fabirt.podcastapp.R 14 | 15 | @OptIn(ExperimentalAnimationApi::class) 16 | @Composable 17 | fun AnimatedTitle(visible: Boolean) { 18 | val titleEnterTransition = fadeIn( 19 | animationSpec = tween(1000, 1600) 20 | ) + slideInVertically( 21 | initialOffsetY = { -100 }, 22 | animationSpec = tween(1000, 1600) 23 | ) 24 | 25 | AnimatedVisibility( 26 | visible = visible, 27 | enter = titleEnterTransition, 28 | ) { 29 | Text( 30 | text = stringResource(R.string.welcome_title), 31 | style = MaterialTheme.typography.h1, 32 | textAlign = TextAlign.Center 33 | ) 34 | } 35 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/ui/welcome/WelcomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.ui.welcome 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.Surface 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import androidx.compose.ui.unit.dp 10 | import com.fabirt.podcastapp.ui.common.PreviewContent 11 | import com.fabirt.podcastapp.ui.navigation.Destination 12 | import com.fabirt.podcastapp.ui.navigation.Navigator 13 | 14 | @Composable 15 | fun WelcomeScreen() { 16 | var visible by remember { mutableStateOf(false) } 17 | val navController = Navigator.current 18 | 19 | LaunchedEffect(true) { 20 | visible = true 21 | } 22 | 23 | WelcomeScreenContent(visible = visible) { 24 | navController.navigate(Destination.home) { 25 | popUpTo(Destination.welcome) { inclusive = true } 26 | } 27 | } 28 | } 29 | 30 | @Composable 31 | fun WelcomeScreenContent( 32 | visible: Boolean, 33 | onGetStarted: () -> Unit 34 | ) { 35 | Surface { 36 | Column( 37 | horizontalAlignment = Alignment.CenterHorizontally, 38 | verticalArrangement = Arrangement.Center, 39 | modifier = Modifier.fillMaxSize() 40 | ) { 41 | AnimatedTitle(visible = visible) 42 | 43 | AnimatedImage(visible = visible) 44 | 45 | Spacer(modifier = Modifier.height(16.dp)) 46 | 47 | AnimatedButton(visible = visible, onClick = onGetStarted) 48 | } 49 | } 50 | } 51 | 52 | @Preview(name = "Welcome") 53 | @Composable 54 | fun WelcomeScreenPreview() { 55 | PreviewContent { 56 | WelcomeScreenContent(visible = true) { } 57 | } 58 | } 59 | 60 | @Preview(name = "Welcome (Dark)") 61 | @Composable 62 | fun WelcomeScreenDarkPreview() { 63 | PreviewContent(darkTheme = true) { 64 | WelcomeScreenContent(visible = true) { } 65 | } 66 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/util/Date.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.util 2 | 3 | import java.time.Instant 4 | import java.time.ZoneId 5 | import java.time.format.DateTimeFormatter 6 | 7 | fun Long.formatMillisecondsAsDate( 8 | pattern: String = "yyyy-MM-dd HH:mm:ss" 9 | ): String { 10 | val dateFormatter = DateTimeFormatter.ofPattern(pattern).withZone(ZoneId.systemDefault()) 11 | val instant = Instant.ofEpochMilli(this) 12 | return dateFormatter.format(instant) 13 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/util/Either.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.util 2 | 3 | fun right(r: T) = Either.Right(r) 4 | fun left(l: T) = Either.Left(l) 5 | 6 | sealed class Either { 7 | data class Left(val l: L) : Either() 8 | data class Right(val r: R) : Either() 9 | 10 | fun isLeft(): Boolean = this is Left 11 | 12 | fun isRight(): Boolean = this is Right 13 | 14 | fun fold(fnL: (L) -> T, fnR: (R) -> T): T { 15 | return when (this) { 16 | is Left -> fnL(l) 17 | is Right -> fnR(r) 18 | } 19 | } 20 | 21 | fun getOrNull(): R? { 22 | return if (this is Right) { 23 | r 24 | } else { 25 | null 26 | } 27 | } 28 | } 29 | 30 | fun Either.getOrElse(defaultValue: R): R { 31 | return when (this) { 32 | is Either.Right -> r 33 | else -> defaultValue 34 | } 35 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/util/Locale.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.util 2 | 3 | import androidx.compose.ui.text.intl.Locale 4 | 5 | val regionCode = Locale.current.region.toLowerCase() 6 | 7 | val languageCode = Locale.current.language -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/util/Number.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.util 2 | 3 | fun Long.toDurationMinutes(): String { 4 | val minutes = (this / 60).toInt() 5 | 6 | return "$minutes min" 7 | } -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/util/PlaybackStateCompatExt.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.util 2 | 3 | import android.os.SystemClock 4 | import android.support.v4.media.session.PlaybackStateCompat 5 | 6 | inline val PlaybackStateCompat.isPrepared: Boolean 7 | get() = state == PlaybackStateCompat.STATE_BUFFERING || 8 | state == PlaybackStateCompat.STATE_PLAYING || 9 | state == PlaybackStateCompat.STATE_PAUSED 10 | 11 | inline val PlaybackStateCompat.isPlaying: Boolean 12 | get() = state == PlaybackStateCompat.STATE_BUFFERING || 13 | state == PlaybackStateCompat.STATE_PLAYING 14 | 15 | inline val PlaybackStateCompat.isPlayEnabled: Boolean 16 | get() = actions and PlaybackStateCompat.ACTION_PLAY != 0L || 17 | (actions and PlaybackStateCompat.ACTION_PLAY_PAUSE != 0L && 18 | state == PlaybackStateCompat.STATE_PAUSED) 19 | 20 | inline val PlaybackStateCompat.isStopped: Boolean 21 | get() = state == PlaybackStateCompat.STATE_NONE || 22 | state == PlaybackStateCompat.STATE_ERROR 23 | 24 | inline val PlaybackStateCompat.isError: Boolean 25 | get() = state == PlaybackStateCompat.STATE_ERROR 26 | 27 | inline val PlaybackStateCompat.currentPosition: Long 28 | get() = if (state == PlaybackStateCompat.STATE_PLAYING) { 29 | val timeDelta = SystemClock.elapsedRealtime() - lastPositionUpdateTime 30 | (position + (timeDelta * playbackSpeed)).toLong() 31 | } else position -------------------------------------------------------------------------------- /android/app/src/main/java/com/fabirt/podcastapp/util/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp.util 2 | 3 | import com.fabirt.podcastapp.error.Failure 4 | 5 | sealed class Resource { 6 | data class Success(val data: T) : Resource() 7 | data class Error(val failure: Failure) : Resource() 8 | object Loading : Resource() 9 | } -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/gradient_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_microphone.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_music_file.xml: -------------------------------------------------------------------------------- 1 | 3 | 7 | 11 | 15 | 19 | 20 | 21 | 22 | 26 | 27 | 31 | 35 | 36 | 40 | 41 | 42 | 43 | 47 | 51 | 55 | 59 | 60 | 64 | 68 | 72 | 76 | 80 | 84 | 85 | 86 | 90 | 94 | 98 | 102 | 106 | 110 | 114 | 118 | 122 | 126 | 130 | 134 | 138 | 142 | 146 | 150 | 154 | 158 | 162 | 163 | 167 | 171 | 175 | 179 | 183 | 187 | 191 | 195 | 199 | 203 | 207 | 211 | 215 | 216 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_podcast.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_round_forward_10.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_round_pause.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_round_play_arrow.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_round_replay_10.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/font/nunito_sans_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/android/app/src/main/res/font/nunito_sans_bold.ttf -------------------------------------------------------------------------------- /android/app/src/main/res/font/nunito_sans_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/android/app/src/main/res/font/nunito_sans_light.ttf -------------------------------------------------------------------------------- /android/app/src/main/res/font/nunito_sans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/android/app/src/main/res/font/nunito_sans_regular.ttf -------------------------------------------------------------------------------- /android/app/src/main/res/font/nunito_sans_semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/android/app/src/main/res/font/nunito_sans_semibold.ttf -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF000000 4 | #FFFFFFFF 5 | #F45B49 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Listen Notes 3 | Get Started 4 | Say it with\nPodcast 5 | Trending Now 6 | Podcast thumbnail 7 | back 8 | Play 9 | Share 10 | Listen Notes URL 11 | An unexpected error occurred :(\nPlease try again 12 | Try again 13 | Take a look at this podcast: %1$s %2$s 14 | Podcasts 15 | Playback notification for Listen Notes 16 | Pause 17 | Close 18 | Replay 10 seconds 19 | Forward 10 seconds 20 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 20 | -------------------------------------------------------------------------------- /android/app/src/test/java/com/fabirt/podcastapp/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.fabirt.podcastapp 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | ext { 4 | kotlin_version = '1.5.10' // Compose Compiler dictates Kotlin version now! 5 | kotlin_coroutines_version = '1.5.0' 6 | compose_version = '1.0.0' 7 | accompanist_version = '0.12.0' 8 | room_version = "2.2.6" 9 | hilt_version = "2.37" 10 | retrofit_version = "2.9.0" 11 | exo_player_version = "2.14.1" 12 | glide_version = "4.12.0" 13 | } 14 | repositories { 15 | google() 16 | mavenCentral() 17 | } 18 | dependencies { 19 | classpath 'com.android.tools.build:gradle:7.0.0' 20 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 21 | 22 | classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" 23 | } 24 | } 25 | 26 | task clean(type: Delete) { 27 | delete rootProject.buildDir 28 | } -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Apr 30 18:22:17 COT 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /android/gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | google() 5 | mavenCentral() 6 | jcenter() // Warning: this repository is going to shut down soon 7 | } 8 | } 9 | rootProject.name = "PodcastApp" 10 | include ':app' 11 | -------------------------------------------------------------------------------- /demo/detail_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/detail_dark.png -------------------------------------------------------------------------------- /demo/detail_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/detail_light.png -------------------------------------------------------------------------------- /demo/home_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/home_dark.png -------------------------------------------------------------------------------- /demo/home_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/home_light.png -------------------------------------------------------------------------------- /demo/listen-notes-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/listen-notes-demo.gif -------------------------------------------------------------------------------- /demo/notification_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/notification_dark.png -------------------------------------------------------------------------------- /demo/notification_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/notification_light.png -------------------------------------------------------------------------------- /demo/player_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/player_dark.png -------------------------------------------------------------------------------- /demo/player_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/player_light.png -------------------------------------------------------------------------------- /demo/welcome_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/welcome_dark.png -------------------------------------------------------------------------------- /demo/welcome_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabirt/podcast-app/8caa9936ab5b3482ddfa1423168edb677ac6b5d0/demo/welcome_light.png --------------------------------------------------------------------------------