├── 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 | |  |  | | |
37 | |----------|:-------------:|:-------------:|:-------------:|
38 |
39 | ### Light Mode
40 | |  |  | | |
41 | |----------|:-------------:|:-------------:|:-------------:|
42 |
43 | ### Demo
44 | 
--------------------------------------------------------------------------------
/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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | xmlns:android
18 |
19 | ^$
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | xmlns:.*
29 |
30 | ^$
31 |
32 |
33 | BY_NAME
34 |
35 |
36 |
37 |
38 |
39 |
40 | .*:id
41 |
42 | http://schemas.android.com/apk/res/android
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | .*:name
52 |
53 | http://schemas.android.com/apk/res/android
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | name
63 |
64 | ^$
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | style
74 |
75 | ^$
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | .*
85 |
86 | ^$
87 |
88 |
89 | BY_NAME
90 |
91 |
92 |
93 |
94 |
95 |
96 | .*
97 |
98 | http://schemas.android.com/apk/res/android
99 |
100 |
101 | ANDROID_ATTRIBUTE_ORDER
102 |
103 |
104 |
105 |
106 |
107 |
108 | .*
109 |
110 | .*
111 |
112 |
113 | BY_NAME
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/android/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/android/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/android/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
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
--------------------------------------------------------------------------------