From 0b96abe3906c5dd3a31a2e36422f96b15fb4b46e Mon Sep 17 00:00:00 2001 From: Gijs van Veen Date: Fri, 17 Mar 2023 13:16:55 +0100 Subject: [PATCH 01/47] Adding media module --- media/build.gradle.kts | 27 +++ .../androidLibAndroidTest/AndroidManifest.xml | 6 + .../kotlin/TestActivity.kt | 27 +++ media/src/androidLibMain/AndroidManifest.xml | 2 + media/src/commonMain/kotlin/MediaManager.kt | 75 +++++++ media/src/commonMain/kotlin/MediaPlayer.kt | 43 ++++ media/src/commonMain/kotlin/PlaybackState.kt | 191 ++++++++++++++++++ .../commonMain/kotlin/PlaybackStateRepo.kt | 57 ++++++ settings.gradle.kts | 1 + 9 files changed, 429 insertions(+) create mode 100644 media/build.gradle.kts create mode 100644 media/src/androidLibAndroidTest/AndroidManifest.xml create mode 100644 media/src/androidLibAndroidTest/kotlin/TestActivity.kt create mode 100644 media/src/androidLibMain/AndroidManifest.xml create mode 100644 media/src/commonMain/kotlin/MediaManager.kt create mode 100644 media/src/commonMain/kotlin/MediaPlayer.kt create mode 100644 media/src/commonMain/kotlin/PlaybackState.kt create mode 100644 media/src/commonMain/kotlin/PlaybackStateRepo.kt diff --git a/media/build.gradle.kts b/media/build.gradle.kts new file mode 100644 index 000000000..35d76d11f --- /dev/null +++ b/media/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("multiplatform") + id("jacoco") + id("convention.publication") + id("com.android.library") + id("org.jetbrains.dokka") + id("org.jlleitschuh.gradle.ktlint") +} + +publishableComponent() + +dependencies { } + +kotlin { + sourceSets { + commonMain { + dependencies { + implementation(project(":base")) + } + } + commonTest { + dependencies { + implementation(project(":test-utils-base")) + } + } + } +} diff --git a/media/src/androidLibAndroidTest/AndroidManifest.xml b/media/src/androidLibAndroidTest/AndroidManifest.xml new file mode 100644 index 000000000..5ad5e14ea --- /dev/null +++ b/media/src/androidLibAndroidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/media/src/androidLibAndroidTest/kotlin/TestActivity.kt b/media/src/androidLibAndroidTest/kotlin/TestActivity.kt new file mode 100644 index 000000000..d5d48d73e --- /dev/null +++ b/media/src/androidLibAndroidTest/kotlin/TestActivity.kt @@ -0,0 +1,27 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +*/ + +package com.splendo.kaluga.media + +import androidx.appcompat.app.AppCompatActivity + +class TestActivity : AppCompatActivity() + +class CorrectlySetupTest { + @kotlin.test.Test + fun testSetup() = assert(true) +} diff --git a/media/src/androidLibMain/AndroidManifest.xml b/media/src/androidLibMain/AndroidManifest.xml new file mode 100644 index 000000000..c3420c35e --- /dev/null +++ b/media/src/androidLibMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/media/src/commonMain/kotlin/MediaManager.kt b/media/src/commonMain/kotlin/MediaManager.kt new file mode 100644 index 000000000..fd3b2307d --- /dev/null +++ b/media/src/commonMain/kotlin/MediaManager.kt @@ -0,0 +1,75 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlin.time.Duration + +expect class PlayableMedia { + val duration: Duration + val currentPlayTime: Duration + fun playTime(pollingInterval: Duration): Flow +} + +interface MediaManager { + + sealed class Event { + data class DidPrepare(val playableMedia: PlayableMedia) : Event() + data class DidFailWithError(val error: PlaybackError) : Event() + object DidComplete : Event() + } + + val events: Flow + + fun createPlayableMedia(url: String): PlayableMedia? + fun initialize(playableMedia: PlayableMedia) + + fun prepare() + fun play() + fun pause() + fun stop() + fun seekTo(duration: Duration) + fun end() +} + +abstract class BaseMediaManager : MediaManager { + + interface Builder { + fun create(): BaseMediaManager + } + + private val _events = Channel(UNLIMITED) + override val events: Flow = _events.receiveAsFlow() + + protected fun handlePrepared(playableMedia: PlayableMedia) { + _events.trySend(MediaManager.Event.DidPrepare(playableMedia)) + } + + protected fun handleError(error: PlaybackError) { + _events.trySend(MediaManager.Event.DidFailWithError(error)) + } + + protected fun handleCompleted() { + _events.trySend(MediaManager.Event.DidComplete) + } +} + +expect class DefaultMediaManager : BaseMediaManager diff --git a/media/src/commonMain/kotlin/MediaPlayer.kt b/media/src/commonMain/kotlin/MediaPlayer.kt new file mode 100644 index 000000000..c7d3aa177 --- /dev/null +++ b/media/src/commonMain/kotlin/MediaPlayer.kt @@ -0,0 +1,43 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlin.coroutines.CoroutineContext + +interface MediaPlayer { + fun release() +} + +class DefaultMediaPlayer( + private val url: String, + private val mediaManagerBuilder: BaseMediaManager.Builder, + coroutineContext: CoroutineContext +) : MediaPlayer, CoroutineScope by CoroutineScope(coroutineContext + CoroutineName("MediaPlayer for $url")) { + + private val playbackStateRepo = PlaybackStateRepo(url, mediaManagerBuilder, coroutineContext) + private val playbackState = playbackStateRepo.stateIn(this, SharingStarted.Eagerly, PlaybackStateImpl.Uninitialized) + + override fun release() { + + } + +} \ No newline at end of file diff --git a/media/src/commonMain/kotlin/PlaybackState.kt b/media/src/commonMain/kotlin/PlaybackState.kt new file mode 100644 index 000000000..30a596363 --- /dev/null +++ b/media/src/commonMain/kotlin/PlaybackState.kt @@ -0,0 +1,191 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import com.splendo.kaluga.base.flow.SpecialFlowValue +import com.splendo.kaluga.base.state.HandleAfterOldStateIsRemoved +import com.splendo.kaluga.base.state.HandleBeforeOldStateIsRemoved +import com.splendo.kaluga.base.state.KalugaState +import kotlin.time.Duration + +sealed class PlaybackError { + object MalformedMediaSource : PlaybackError() +} + +sealed interface PlaybackState : KalugaState { + + sealed class LoopMode { + object NotLooping : LoopMode() + object LoopingForever : LoopMode() + data class LoopingForFixedNumber(val loops: UInt) : LoopMode() + } + + sealed interface Active : PlaybackState { + val end: suspend () -> Ended + fun failWithError(error: PlaybackError): suspend () -> Error + } + + interface Uninitialized : Active { + fun initialize(url: String): suspend () -> InitializedOrError + } + + sealed interface InitializedOrError : Active + + interface Initialized : InitializedOrError { + fun prepared(media: PlayableMedia): suspend () -> Idle + } + + sealed interface Prepared : Active { + + val playableMedia: PlayableMedia + + val stop: suspend () -> Stopped + fun seekTo(duration: Duration) + } + + interface Idle : Prepared { + fun start(loopMode: LoopMode = LoopMode.NotLooping): suspend () -> Started + } + + sealed interface Playing : Prepared { + val loopMode: LoopMode + val updateLoopMode: suspend () -> Playing + } + + interface Started : Playing { + val pause: suspend () -> Paused + val completed: suspend () -> Completed + } + + interface Paused : Playing { + val start: suspend () -> Started + } + + interface Stopped : Active { + val reset: suspend () -> Initialized + } + + interface Completed : Prepared { + val start: suspend () -> Started + } + + interface Error : InitializedOrError { + val error: PlaybackError + } + + interface Ended : PlaybackState, SpecialFlowValue.Last +} + +internal class PlaybackStateImpl { + + sealed class Active { + internal abstract val mediaManager: MediaManager + + val end: suspend () -> PlaybackState.Ended = { Ended } + fun failWithError(error: PlaybackError) = suspend { Error(error, mediaManager) } + } + + data class Uninitialized(override val mediaManager: MediaManager) : Active(), PlaybackState.Uninitialized { + override fun initialize(url: String): suspend () -> PlaybackState.InitializedOrError = mediaManager.createPlayableMedia(url)?.let { + { Initialized(it, mediaManager) } + } ?: failWithError(PlaybackError.MalformedMediaSource) + } + + data class Initialized(private val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Active(), PlaybackState.Initialized, HandleAfterOldStateIsRemoved { + override fun prepared(media: PlayableMedia): suspend () -> PlaybackState.Idle = { Idle(media, mediaManager) } + + override suspend fun afterOldStateIsRemoved(oldState: PlaybackState) { + when (oldState) { + is Uninitialized, is Stopped -> mediaManager.initialize(playableMedia) + else -> {} + } + } + } + + sealed class Prepared : Active() { + + abstract override val mediaManager: MediaManager + abstract val playableMedia: PlayableMedia + + val stop: suspend () -> PlaybackState.Stopped = { Stopped(playableMedia, mediaManager) } + fun seekTo(duration: Duration) = mediaManager.seekTo(duration) + } + + data class Idle(override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Idle { + override fun start(loopMode: PlaybackState.LoopMode): suspend () -> PlaybackState.Started = { + Started(loopMode, playableMedia, mediaManager) + } + } + + data class Started(override val loopMode: PlaybackState.LoopMode, override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Started, HandleBeforeOldStateIsRemoved { + + override val completed: suspend () -> PlaybackState.Completed = { Completed(loopMode, playableMedia, mediaManager) } + override val pause: suspend () -> PlaybackState.Paused = { Paused(loopMode, playableMedia, mediaManager) } + override val updateLoopMode: suspend () -> PlaybackState.Playing = { copy(loopMode = loopMode) } + + override suspend fun beforeOldStateIsRemoved(oldState: PlaybackState) { + when (oldState) { + !is PlaybackState.Started -> mediaManager.play() + else -> {} + } + } + } + + data class Paused(override val loopMode: PlaybackState.LoopMode, override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Paused, HandleAfterOldStateIsRemoved { + + override val start: suspend () -> PlaybackState.Started = { Started(loopMode, playableMedia, mediaManager) } + override val updateLoopMode: suspend () -> PlaybackState.Playing = { copy(loopMode = loopMode) } + override suspend fun afterOldStateIsRemoved(oldState: PlaybackState) { + when (oldState) { + is PlaybackState.Started -> mediaManager.pause() + else -> {} + } + } + } + + data class Stopped(override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Stopped, HandleBeforeOldStateIsRemoved { + + override val reset: suspend () -> PlaybackState.Initialized = { Initialized(playableMedia, mediaManager) } + + override suspend fun beforeOldStateIsRemoved(oldState: PlaybackState) { + when (oldState) { + !is PlaybackState.Started -> mediaManager.play() + else -> {} + } + } + } + + data class Completed(private val loopMode: PlaybackState.LoopMode, override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Completed { + override val start: suspend () -> PlaybackState.Started = { + mediaManager.seekTo(Duration.ZERO) + Started(loopMode, playableMedia, mediaManager) + } + } + + data class Error(override val error: PlaybackError, override val mediaManager: MediaManager) : Active(), PlaybackState.Error + + object Ended : PlaybackState.Ended, HandleAfterOldStateIsRemoved { + override suspend fun afterOldStateIsRemoved(oldState: PlaybackState) { + when (oldState) { + is Active -> oldState.mediaManager?.end() + else -> {} + } + } + + } +} diff --git a/media/src/commonMain/kotlin/PlaybackStateRepo.kt b/media/src/commonMain/kotlin/PlaybackStateRepo.kt new file mode 100644 index 000000000..8d154527b --- /dev/null +++ b/media/src/commonMain/kotlin/PlaybackStateRepo.kt @@ -0,0 +1,57 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import com.splendo.kaluga.base.state.ColdStateFlowRepo +import com.splendo.kaluga.base.state.HotStateFlowRepo +import com.splendo.kaluga.base.state.StateRepo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.coroutines.CoroutineContext + +/** + * A [StateRepo]/[MutableStateFlow] of [PlaybackState] + */ +typealias PlaybackStateFlowRepo = StateRepo> + +/** + * An abstract [ColdStateFlowRepo] for managing [PlaybackState] + * @param createInitialState method for creating the initial [PlaybackState.Uninitialized] given an implementation of this [HotStateFlowRepo] + * @param coroutineContext the [CoroutineContext] the [CoroutineContext] used to create a coroutine scope for this state machine. + */ +abstract class BasePlaybackStateRepo( + createInitialState: HotStateFlowRepo.() -> PlaybackState.Uninitialized, + coroutineContext: CoroutineContext +) : HotStateFlowRepo( + coroutineContext = coroutineContext, + initialState = createInitialState, +) + +/** + * A [BasePlaybackStateRepo] managed using a [MediaManager] + * @param mediaManager the [MediaManager] to manage the [PlaybackState] + * @param coroutineContext the [CoroutineContext] the [CoroutineContext] used to create a coroutine scope for this state machine. + */ +open class PlaybackStateRepo( + mediaManager: MediaManager, + coroutineContext: CoroutineContext +) : BasePlaybackStateRepo( + createInitialState = { + PlaybackStateImpl.Uninitialized(mediaManager = mediaManager) + }, + coroutineContext = coroutineContext +) diff --git a/settings.gradle.kts b/settings.gradle.kts index c2aea3108..8081e2b08 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -80,6 +80,7 @@ include(":location") include(":keyboard") include(":keyboard-compose") include(":links") +include(":media") include(":resources") include(":resources-compose") include(":resources-databinding") From 97c6c4a156157f3cdb383bcbe973d3ec8f499f94 Mon Sep 17 00:00:00 2001 From: Gijs van Veen Date: Tue, 21 Mar 2023 11:44:22 +0100 Subject: [PATCH 02/47] Platform media Manager --- base/build.gradle.kts | 14 +- .../kotlin/kvo/observeKeyValueAsFlow.kt | 69 +++++++ base/src/iosTest/kotlin/kvo/KVOObservation.kt | 42 +++++ .../nativeInterop/cinterop/objectObserver.def | 11 ++ .../src/main/kotlin/Component.kt | 23 ++- .../src/main/kotlin/PublishableComponent.kt | 9 +- .../kotlin/DefaultMediaManager.kt | 100 ++++++++++ media/src/commonMain/kotlin/MediaManager.kt | 17 +- media/src/commonMain/kotlin/MediaPlayer.kt | 156 +++++++++++++++- media/src/commonMain/kotlin/PlaybackState.kt | 47 +++-- .../src/iosMain/kotlin/DefaultMediaManager.kt | 172 ++++++++++++++++++ .../src/jsMain/kotlin/DefaultMediaManager.kt | 55 ++++++ .../src/jvmMain/kotlin/DefaultMediaManager.kt | 55 ++++++ 13 files changed, 739 insertions(+), 31 deletions(-) create mode 100644 base/src/iosMain/kotlin/kvo/observeKeyValueAsFlow.kt create mode 100644 base/src/iosTest/kotlin/kvo/KVOObservation.kt create mode 100644 base/src/nativeInterop/cinterop/objectObserver.def create mode 100644 media/src/androidLibMain/kotlin/DefaultMediaManager.kt create mode 100644 media/src/iosMain/kotlin/DefaultMediaManager.kt create mode 100644 media/src/jsMain/kotlin/DefaultMediaManager.kt create mode 100644 media/src/jvmMain/kotlin/DefaultMediaManager.kt diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 5a9af9e16..d482c917d 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -8,7 +8,19 @@ plugins { id("kotlinx-atomicfu") } -publishableComponent() +publishableComponent( + iosMainInterop = { + create("objectObserver").apply { + defFile = project.file("src/nativeInterop/cinterop/objectObserver.def") + packageName("com.splendo.kaluga.base.kvo") + compilerOpts ("-I/src/nativeInterop/cinterop") + linkerOpts ("-I/src/nativeInterop/cinterop") + includeDirs{ + allHeaders("src/nativeInterop/cinterop") + } + } + } +) dependencies { implementationDependency(Dependencies.KotlinX.AtomicFu) diff --git a/base/src/iosMain/kotlin/kvo/observeKeyValueAsFlow.kt b/base/src/iosMain/kotlin/kvo/observeKeyValueAsFlow.kt new file mode 100644 index 000000000..7ea387f21 --- /dev/null +++ b/base/src/iosMain/kotlin/kvo/observeKeyValueAsFlow.kt @@ -0,0 +1,69 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.base.kvo + +import com.splendo.kaluga.base.flow.SharedFlowCollectionEvent +import com.splendo.kaluga.base.flow.onCollectionEvent +import kotlinx.cinterop.COpaquePointer +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import platform.Foundation.NSKeyValueObservingOptionNew +import platform.Foundation.NSKeyValueObservingOptions +import platform.Foundation.addObserver +import platform.Foundation.removeObserver +import platform.Foundation.valueForKeyPath +import platform.darwin.NSObject +import kotlin.coroutines.CoroutineContext + +@Suppress("UNCHECKED_CAST") +private class KVOObserver : NSObject(), NSObjectObserverProtocol { + + val observedValue = MutableSharedFlow(1) + + override fun observeValueForKeyPath( + keyPath: String?, + ofObject: Any?, + change: Map?, + context: COpaquePointer? + ) { + val value = (ofObject as NSObject).valueForKeyPath(keyPath!!) + observedValue.tryEmit(value as T) + } +} + +fun NSObject.observeKeyValueAsFlow( + keyPath: String, + options: NSKeyValueObservingOptions = NSKeyValueObservingOptionNew, + coroutineContext: CoroutineContext +): Flow { + val observer = KVOObserver() + CoroutineScope(coroutineContext + CoroutineName("Observing $keyPath")).launch { + observer.observedValue.onCollectionEvent { event -> + when (event) { + SharedFlowCollectionEvent.NoMoreCollections -> removeObserver(observer, keyPath) + SharedFlowCollectionEvent.FirstCollection -> addObserver(observer, keyPath, options, null) + SharedFlowCollectionEvent.LaterCollections -> {} + } + } + } + return observer.observedValue.asSharedFlow() +} diff --git a/base/src/iosTest/kotlin/kvo/KVOObservation.kt b/base/src/iosTest/kotlin/kvo/KVOObservation.kt new file mode 100644 index 000000000..0c6a79069 --- /dev/null +++ b/base/src/iosTest/kotlin/kvo/KVOObservation.kt @@ -0,0 +1,42 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.base.kvo + +import com.splendo.kaluga.base.runBlocking +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.yield +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSKeyValueObservingOptionInitial +import platform.Foundation.NSLocale +import kotlin.test.Test +import kotlin.test.assertEquals + +class KVOObservation { + + @Test + fun testObserveKeyValueAsFlow() = runBlocking { + val kvoTestClass = NSDateFormatter() + val flow = kvoTestClass.observeKeyValueAsFlow("locale", NSKeyValueObservingOptionInitial, coroutineContext) + assertEquals(kvoTestClass.locale, flow.first()) + kvoTestClass.locale = NSLocale("nl_NL") + yield() + assertEquals(NSLocale("nl_NL"), flow.first()) + coroutineContext.cancelChildren() + } +} diff --git a/base/src/nativeInterop/cinterop/objectObserver.def b/base/src/nativeInterop/cinterop/objectObserver.def new file mode 100644 index 000000000..2aa84c7eb --- /dev/null +++ b/base/src/nativeInterop/cinterop/objectObserver.def @@ -0,0 +1,11 @@ +language = Objective-C +--- +#import + +@protocol NSObjectObserver +@required +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context; +@end; \ No newline at end of file diff --git a/kaluga-library-components/src/main/kotlin/Component.kt b/kaluga-library-components/src/main/kotlin/Component.kt index f1ec2818c..2d415bed6 100644 --- a/kaluga-library-components/src/main/kotlin/Component.kt +++ b/kaluga-library-components/src/main/kotlin/Component.kt @@ -16,11 +16,13 @@ */ import com.android.build.gradle.LibraryExtension +import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project import org.gradle.api.tasks.Copy import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType +import org.jetbrains.kotlin.gradle.plugin.mpp.DefaultCInteropSettings import org.jetbrains.kotlin.gradle.plugin.mpp.Framework import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithSimulatorTests @@ -32,11 +34,15 @@ sealed class ComponentType { object DataBinding : ComponentType() } -fun Project.commonComponent(iosExport: (Framework.() -> Unit)? = null) { +fun Project.commonComponent( + iosMainInterop: (NamedDomainObjectContainer.() -> Unit)? = null, + iosTestInterop: (NamedDomainObjectContainer.() -> Unit)? = null, + iosExport: (Framework.() -> Unit)? = null +) { group = Library.group version = Library.version kotlinMultiplatform { - commonMultiplatformComponent(this@commonComponent, iosExport) + commonMultiplatformComponent(this@commonComponent, iosMainInterop, iosTestInterop, iosExport) } commonAndroidComponent() @@ -83,7 +89,12 @@ fun Project.commonComponent(iosExport: (Framework.() -> Unit)? = null) { } } -fun KotlinMultiplatformExtension.commonMultiplatformComponent(currentProject: Project, iosExport: (Framework.() -> Unit)? = null) { +fun KotlinMultiplatformExtension.commonMultiplatformComponent( + currentProject: Project, + iosMainInterop: (NamedDomainObjectContainer.() -> Unit)? = null, + iosTestInterop: (NamedDomainObjectContainer.() -> Unit)? = null, + iosExport: (Framework.() -> Unit)? = null +) { targets { configureEach { compilations.configureEach { @@ -95,6 +106,12 @@ fun KotlinMultiplatformExtension.commonMultiplatformComponent(currentProject: Pr android("androidLib").publishAllLibraryVariants() val target: KotlinNativeTarget.() -> Unit = { + iosMainInterop?.let { mainInterop -> + compilations.getByName("main").cinterops.mainInterop() + } + iosTestInterop?.let { testInterop -> + compilations.getByName("test").cinterops.testInterop() + } binaries { iosExport?.let { iosExport -> framework { diff --git a/kaluga-library-components/src/main/kotlin/PublishableComponent.kt b/kaluga-library-components/src/main/kotlin/PublishableComponent.kt index c713fd46c..a4b23c760 100644 --- a/kaluga-library-components/src/main/kotlin/PublishableComponent.kt +++ b/kaluga-library-components/src/main/kotlin/PublishableComponent.kt @@ -15,9 +15,14 @@ */ +import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.plugin.mpp.DefaultCInteropSettings -fun Project.publishableComponent() { - commonComponent() +fun Project.publishableComponent( + iosMainInterop: (NamedDomainObjectContainer.() -> Unit)? = null, + iosTestInterop: (NamedDomainObjectContainer.() -> Unit)? = null, +) { + commonComponent(iosMainInterop, iosTestInterop) publish() } \ No newline at end of file diff --git a/media/src/androidLibMain/kotlin/DefaultMediaManager.kt b/media/src/androidLibMain/kotlin/DefaultMediaManager.kt new file mode 100644 index 000000000..eb378f73f --- /dev/null +++ b/media/src/androidLibMain/kotlin/DefaultMediaManager.kt @@ -0,0 +1,100 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import java.io.IOException +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +typealias AndroidMediaPlayer = android.media.MediaPlayer + +actual class PlayableMedia(private val mediaPlayer: AndroidMediaPlayer) { + actual val duration: Duration get() = mediaPlayer.duration.milliseconds + actual val currentPlayTime: Duration get() = mediaPlayer.currentPosition.milliseconds +} + +actual class DefaultMediaManager(coroutineContext: CoroutineContext) : BaseMediaManager(coroutineContext) { + + class Builder : BaseMediaManager.Builder { + override fun create(coroutineContext: CoroutineContext): BaseMediaManager = DefaultMediaManager(coroutineContext) + } + + private val mediaPlayer = AndroidMediaPlayer() + + init { + mediaPlayer.setOnCompletionListener { handleCompleted() } + mediaPlayer.setOnErrorListener { _, _, extra -> + val error = when (extra) { + AndroidMediaPlayer.MEDIA_ERROR_IO -> PlaybackError.IO + AndroidMediaPlayer.MEDIA_ERROR_MALFORMED -> PlaybackError.MalformedMediaSource + AndroidMediaPlayer.MEDIA_ERROR_UNSUPPORTED -> PlaybackError.Unsupported + AndroidMediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK -> PlaybackError.Unsupported + AndroidMediaPlayer.MEDIA_ERROR_TIMED_OUT -> PlaybackError.TimedOut + else -> PlaybackError.Unknown + } + handleError(error) + false + } + } + + override fun cleanUp() { + mediaPlayer.release() + } + + override fun createPlayableMedia(url: String): PlayableMedia? = try { + mediaPlayer.setDataSource(url) + PlayableMedia(mediaPlayer) + } catch (e: Throwable) { + when (e) { + is IllegalStateException -> null + is IOException -> null + is IllegalArgumentException -> null + is SecurityException -> null + else -> throw e + } + } + + override fun initialize(playableMedia: PlayableMedia) { + mediaPlayer.setOnPreparedListener { + mediaPlayer.setOnPreparedListener(null) + handlePrepared(playableMedia) + } + mediaPlayer.prepareAsync() + } + + override fun play() = try { + mediaPlayer.start() + } catch (e: IllegalStateException) { + handleError(PlaybackError.Unknown) + } + + override fun pause() = try { + mediaPlayer.pause() + } catch (e: IllegalStateException) { + handleError(PlaybackError.Unknown) + } + + override fun stop() = try { + mediaPlayer.stop() + } catch (e: IllegalStateException) { + handleError(PlaybackError.Unknown) + } + + override fun seekTo(duration: Duration) = mediaPlayer.seekTo(duration.inWholeMilliseconds.toInt()) +} \ No newline at end of file diff --git a/media/src/commonMain/kotlin/MediaManager.kt b/media/src/commonMain/kotlin/MediaManager.kt index fd3b2307d..ae72ec222 100644 --- a/media/src/commonMain/kotlin/MediaManager.kt +++ b/media/src/commonMain/kotlin/MediaManager.kt @@ -17,16 +17,18 @@ package com.splendo.kaluga.media +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow +import kotlin.coroutines.CoroutineContext import kotlin.time.Duration expect class PlayableMedia { val duration: Duration val currentPlayTime: Duration - fun playTime(pollingInterval: Duration): Flow } interface MediaManager { @@ -35,6 +37,7 @@ interface MediaManager { data class DidPrepare(val playableMedia: PlayableMedia) : Event() data class DidFailWithError(val error: PlaybackError) : Event() object DidComplete : Event() + object DidEnd : Event() } val events: Flow @@ -42,7 +45,6 @@ interface MediaManager { fun createPlayableMedia(url: String): PlayableMedia? fun initialize(playableMedia: PlayableMedia) - fun prepare() fun play() fun pause() fun stop() @@ -50,10 +52,10 @@ interface MediaManager { fun end() } -abstract class BaseMediaManager : MediaManager { +abstract class BaseMediaManager(coroutineContext: CoroutineContext) : MediaManager, CoroutineScope by CoroutineScope(coroutineContext + CoroutineName("MediaManager")) { interface Builder { - fun create(): BaseMediaManager + fun create(coroutineContext: CoroutineContext): BaseMediaManager } private val _events = Channel(UNLIMITED) @@ -70,6 +72,13 @@ abstract class BaseMediaManager : MediaManager { protected fun handleCompleted() { _events.trySend(MediaManager.Event.DidComplete) } + + override fun end() { + cleanUp() + _events.trySend(MediaManager.Event.DidEnd) + } + + protected abstract fun cleanUp() } expect class DefaultMediaManager : BaseMediaManager diff --git a/media/src/commonMain/kotlin/MediaPlayer.kt b/media/src/commonMain/kotlin/MediaPlayer.kt index c7d3aa177..9a207fc69 100644 --- a/media/src/commonMain/kotlin/MediaPlayer.kt +++ b/media/src/commonMain/kotlin/MediaPlayer.kt @@ -17,27 +17,167 @@ package com.splendo.kaluga.media +import com.splendo.kaluga.base.flow.collectUntilLast +import com.splendo.kaluga.base.utils.firstInstance +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration interface MediaPlayer { - fun release() + + val playableMedia: Flow + + val canStart: Flow + suspend fun start(loopMode: PlaybackState.LoopMode = PlaybackState.LoopMode.NotLooping) + + val canPause: Flow + suspend fun pause() + + val canStop: Flow + suspend fun stop() + + suspend fun awaitCompletion() + + fun end() } class DefaultMediaPlayer( private val url: String, - private val mediaManagerBuilder: BaseMediaManager.Builder, + mediaManagerBuilder: BaseMediaManager.Builder, coroutineContext: CoroutineContext ) : MediaPlayer, CoroutineScope by CoroutineScope(coroutineContext + CoroutineName("MediaPlayer for $url")) { - private val playbackStateRepo = PlaybackStateRepo(url, mediaManagerBuilder, coroutineContext) - private val playbackState = playbackStateRepo.stateIn(this, SharingStarted.Eagerly, PlaybackStateImpl.Uninitialized) + private val mediaManager = mediaManagerBuilder.create(coroutineContext) + private val playbackStateRepo = PlaybackStateRepo(mediaManager, coroutineContext) + + init { + launch { + mediaManager.events.collect { event -> + when (event) { + is MediaManager.Event.DidPrepare -> playbackStateRepo.takeAndChangeState(PlaybackState.Initialized::class) { it.prepared(event.playableMedia) } + is MediaManager.Event.DidFailWithError -> playbackStateRepo.takeAndChangeState(PlaybackState.Active::class) { it.failWithError(event.error) } + is MediaManager.Event.DidComplete -> playbackStateRepo.takeAndChangeState(PlaybackState.Started::class) { it.completed } + is MediaManager.Event.DidEnd -> playbackStateRepo.takeAndChangeState(PlaybackState.Active::class) { it.end } + } + } + } + launch { + playbackStateRepo.collectUntilLast(false) { state -> + when (state) { + is PlaybackState.Uninitialized -> playbackStateRepo.takeAndChangeState(PlaybackState.Uninitialized::class) { it.initialize(url) } + is PlaybackState.Completed -> playbackStateRepo.takeAndChangeState(PlaybackState.Completed::class) { it.restartIfLooping } + else -> {} + } + } + }.invokeOnCompletion { + if (it != null) { + mediaManager.end() + } + } + } + + override val playableMedia: Flow = playbackStateRepo.map { + (it as? PlaybackState.Prepared)?.playableMedia + }.distinctUntilChanged() + + override val canStart: Flow = playbackStateRepo.map { state -> + when (state) { + is PlaybackState.Uninitialized, + is PlaybackState.Initialized, + is PlaybackState.Started, + is PlaybackState.Error, + is PlaybackState.Ended -> false + is PlaybackState.Idle, + is PlaybackState.Paused, + is PlaybackState.Completed, + is PlaybackState.Stopped -> true + } + } + + override suspend fun start(loopMode: PlaybackState.LoopMode) = playbackStateRepo.transformLatest { state -> + when (state) { + is PlaybackState.Uninitialized -> {} // Do nothing until Initialized + is PlaybackState.Initialized -> {} // Do nothing until prepared + is PlaybackState.Idle -> playbackStateRepo.takeAndChangeState(PlaybackState.Idle::class) { it.start(loopMode) } + is PlaybackState.Completed -> playbackStateRepo.takeAndChangeState(PlaybackState.Completed::class) { it.start(loopMode) } + is PlaybackState.Stopped -> playbackStateRepo.takeAndChangeState(PlaybackState.Stopped::class) { it.reset } + is PlaybackState.Started -> emit(Unit) + is PlaybackState.Paused -> playbackStateRepo.takeAndChangeState(PlaybackState.Paused::class) { it.start } + is PlaybackState.Ended -> throw PlaybackError.PlaybackHasEnded + is PlaybackState.Error -> throw state.error + } + }.first() + + override val canPause: Flow = playbackStateRepo.map { state -> + state is PlaybackState.Started + } - override fun release() { + override suspend fun pause() = playbackStateRepo.transformLatest { state -> + when (state) { + is PlaybackState.Playing -> playbackStateRepo.takeAndChangeState(PlaybackState.Started::class) { it.pause } + is PlaybackState.Paused -> emit(Unit) + is PlaybackState.Ended -> throw PlaybackError.PlaybackHasEnded + is PlaybackState.Error -> throw state.error + is PlaybackState.Active -> emit(Unit) // If not playing we should consider it paused + } + }.first() + override val canStop: Flow = playbackStateRepo.map { state -> + state is PlaybackState.Prepared } -} \ No newline at end of file + override suspend fun stop() = playbackStateRepo.transformLatest { state -> + when (state) { + is PlaybackState.Prepared -> playbackStateRepo.takeAndChangeState(PlaybackState.Prepared::class) { it.stop } + is PlaybackState.Stopped -> emit(Unit) + is PlaybackState.Ended -> throw PlaybackError.PlaybackHasEnded + is PlaybackState.Error -> throw state.error + is PlaybackState.Active -> emit(Unit) // If not playing we should consider it stopped + } + }.first() + + override suspend fun awaitCompletion() = playbackStateRepo.transformLatest { state -> + when (state) { + is PlaybackState.Completed -> if (!state.willLoop) { + emit(Unit) + } + is PlaybackState.Ended -> throw PlaybackError.PlaybackHasEnded + is PlaybackState.Error -> throw state.error + is PlaybackState.Active -> {} // Wait until completed + } + }.first() + + override fun end() { + launch { playbackStateRepo.takeAndChangeState(PlaybackState.Active::class) { it.end } } + } +} + +val MediaPlayer.duration: Flow get() = playableMedia.map { it?.duration ?: Duration.ZERO } +fun MediaPlayer.playTime(pollingInterval: Duration) = playableMedia.transformLatest { media -> + media?.let { + while (currentCoroutineContext().isActive) { + emit(it.currentPlayTime) + delay(pollingInterval) + } + } ?: emit(Duration.ZERO) +} + +suspend fun MediaPlayer.play(loopMode: PlaybackState.LoopMode = PlaybackState.LoopMode.NotLooping) { + start(loopMode) + awaitCompletion() +} diff --git a/media/src/commonMain/kotlin/PlaybackState.kt b/media/src/commonMain/kotlin/PlaybackState.kt index 30a596363..c30a31592 100644 --- a/media/src/commonMain/kotlin/PlaybackState.kt +++ b/media/src/commonMain/kotlin/PlaybackState.kt @@ -23,8 +23,13 @@ import com.splendo.kaluga.base.state.HandleBeforeOldStateIsRemoved import com.splendo.kaluga.base.state.KalugaState import kotlin.time.Duration -sealed class PlaybackError { +sealed class PlaybackError : Exception() { + object Unsupported : PlaybackError() + object IO : PlaybackError() + object TimedOut : PlaybackError() object MalformedMediaSource : PlaybackError() + object PlaybackHasEnded : PlaybackError() + object Unknown : PlaybackError() } sealed interface PlaybackState : KalugaState { @@ -81,7 +86,9 @@ sealed interface PlaybackState : KalugaState { } interface Completed : Prepared { - val start: suspend () -> Started + val willLoop: Boolean + fun start(loopMode: LoopMode = LoopMode.NotLooping): suspend () -> Started + val restartIfLooping: suspend () -> Prepared } interface Error : InitializedOrError { @@ -134,7 +141,17 @@ internal class PlaybackStateImpl { data class Started(override val loopMode: PlaybackState.LoopMode, override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Started, HandleBeforeOldStateIsRemoved { - override val completed: suspend () -> PlaybackState.Completed = { Completed(loopMode, playableMedia, mediaManager) } + override val completed: suspend () -> PlaybackState.Completed = { + val newLoopMode = when (loopMode) { + is PlaybackState.LoopMode.NotLooping -> PlaybackState.LoopMode.NotLooping + is PlaybackState.LoopMode.LoopingForever -> PlaybackState.LoopMode.LoopingForever + is PlaybackState.LoopMode.LoopingForFixedNumber -> { + val remainingLoops = loopMode.loops.toInt() - 1 + if (remainingLoops > 0) PlaybackState.LoopMode.LoopingForFixedNumber(remainingLoops.toUInt()) else PlaybackState.LoopMode.NotLooping + } + } + Completed(newLoopMode, playableMedia, mediaManager) + } override val pause: suspend () -> PlaybackState.Paused = { Paused(loopMode, playableMedia, mediaManager) } override val updateLoopMode: suspend () -> PlaybackState.Playing = { copy(loopMode = loopMode) } @@ -171,21 +188,25 @@ internal class PlaybackStateImpl { } data class Completed(private val loopMode: PlaybackState.LoopMode, override val playableMedia: PlayableMedia, override val mediaManager: MediaManager) : Prepared(), PlaybackState.Completed { - override val start: suspend () -> PlaybackState.Started = { + + override val willLoop: Boolean = when (loopMode) { + is PlaybackState.LoopMode.NotLooping -> false + is PlaybackState.LoopMode.LoopingForever -> true + is PlaybackState.LoopMode.LoopingForFixedNumber -> loopMode.loops > 0U + } + + override fun start(loopMode: PlaybackState.LoopMode): suspend () -> PlaybackState.Started = { mediaManager.seekTo(Duration.ZERO) Started(loopMode, playableMedia, mediaManager) } + + override val restartIfLooping: suspend () -> PlaybackState.Prepared get() = when { + willLoop -> start(loopMode) + else -> remain() + } } data class Error(override val error: PlaybackError, override val mediaManager: MediaManager) : Active(), PlaybackState.Error - object Ended : PlaybackState.Ended, HandleAfterOldStateIsRemoved { - override suspend fun afterOldStateIsRemoved(oldState: PlaybackState) { - when (oldState) { - is Active -> oldState.mediaManager?.end() - else -> {} - } - } - - } + object Ended : PlaybackState.Ended } diff --git a/media/src/iosMain/kotlin/DefaultMediaManager.kt b/media/src/iosMain/kotlin/DefaultMediaManager.kt new file mode 100644 index 000000000..0a2680815 --- /dev/null +++ b/media/src/iosMain/kotlin/DefaultMediaManager.kt @@ -0,0 +1,172 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import com.splendo.kaluga.base.kvo.observeKeyValueAsFlow +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.getAndUpdate +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import platform.AVFoundation.AVErrorContentIsNotAuthorized +import platform.AVFoundation.AVErrorContentIsProtected +import platform.AVFoundation.AVErrorContentIsUnavailable +import platform.AVFoundation.AVErrorContentNotUpdated +import platform.AVFoundation.AVErrorDecodeFailed +import platform.AVFoundation.AVErrorFailedToParse +import platform.AVFoundation.AVErrorFormatUnsupported +import platform.AVFoundation.AVErrorNoLongerPlayable +import platform.AVFoundation.AVFoundationErrorDomain +import platform.AVFoundation.AVPlayer +import platform.AVFoundation.AVPlayerItem +import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification +import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeErrorKey +import platform.AVFoundation.AVPlayerItemFailedToPlayToEndTimeNotification +import platform.AVFoundation.AVPlayerItemStatus +import platform.AVFoundation.AVPlayerItemStatusReadyToPlay +import platform.AVFoundation.AVPlayerItemStatusUnknown +import platform.AVFoundation.AVPlayerStatus +import platform.AVFoundation.AVPlayerStatusFailed +import platform.AVFoundation.AVPlayerStatusReadyToPlay +import platform.AVFoundation.AVPlayerStatusUnknown +import platform.AVFoundation.currentItem +import platform.AVFoundation.currentTime +import platform.AVFoundation.duration +import platform.AVFoundation.pause +import platform.AVFoundation.play +import platform.AVFoundation.replaceCurrentItemWithPlayerItem +import platform.AVFoundation.seekToTime +import platform.CoreMedia.CMTimeGetSeconds +import platform.CoreMedia.CMTimeMakeWithSeconds +import platform.Foundation.NSError +import platform.Foundation.NSKeyValueObservingOptionInitial +import platform.Foundation.NSKeyValueObservingOptionNew +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue.Companion.currentQueue +import platform.Foundation.NSOperationQueue.Companion.mainQueue +import platform.Foundation.NSURL +import platform.Foundation.NSURLErrorDomain +import platform.darwin.NSObjectProtocol +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +actual class PlayableMedia(internal val avPlayerItem: AVPlayerItem) { + actual val duration: Duration get() = CMTimeGetSeconds(avPlayerItem.duration).seconds + actual val currentPlayTime: Duration get() = CMTimeGetSeconds(avPlayerItem.currentTime()).seconds +} + +actual class DefaultMediaManager(coroutineContext: CoroutineContext) : BaseMediaManager(coroutineContext) { + + private val avPlayer = AVPlayer(null) + private val observers = atomic>(emptyList()) + + private var itemJob: Job? = null + + init { + launch { + avPlayer.observeKeyValueAsFlow("status", NSKeyValueObservingOptionInitial or NSKeyValueObservingOptionNew, coroutineContext).collect { status -> + when (status) { + AVPlayerStatusUnknown, + AVPlayerStatusReadyToPlay -> {} + AVPlayerStatusFailed -> { + avPlayer.error?.handleError() + } + else -> {} + } + } + } + + observers.value.forEach { NSNotificationCenter.defaultCenter.removeObserver(it) } + observers.value = listOf( + NSNotificationCenter.defaultCenter.addObserverForName( + AVPlayerItemDidPlayToEndTimeNotification, + null, + currentQueue ?: mainQueue + ) { + handleCompleted() + }, + NSNotificationCenter.defaultCenter.addObserverForName( + AVPlayerItemFailedToPlayToEndTimeNotification, + null, + currentQueue ?: mainQueue + ) { + (it?.userInfo?.get(AVPlayerItemFailedToPlayToEndTimeErrorKey) as? NSError)?.handleError() + } + ) + } + + override fun createPlayableMedia(url: String): PlayableMedia? = NSURL.URLWithString(url)?.let { + PlayableMedia(AVPlayerItem(it)) + } + + override fun initialize(playableMedia: PlayableMedia) { + avPlayer.replaceCurrentItemWithPlayerItem(playableMedia.avPlayerItem) + itemJob = launch { + playableMedia.avPlayerItem.observeKeyValueAsFlow("status", NSKeyValueObservingOptionInitial or NSKeyValueObservingOptionNew, coroutineContext).collect { status -> + when (status) { + AVPlayerItemStatusUnknown -> {} + AVPlayerItemStatusReadyToPlay -> handlePrepared(playableMedia) + AVPlayerStatusFailed -> { + avPlayer.error?.handleError() + } + else -> {} + } + } + } + } + + override fun play() = avPlayer.play() + override fun pause() = avPlayer.pause() + override fun stop() { + avPlayer.replaceCurrentItemWithPlayerItem(null) + } + + override fun cleanUp() { + observers.getAndUpdate { emptyList() }.forEach { observer -> + NSNotificationCenter.defaultCenter.removeObserver(observer) + } + cancel() + } + + override fun seekTo(duration: Duration) { + avPlayer.seekToTime(CMTimeMakeWithSeconds(duration.toDouble(DurationUnit.SECONDS), 1)) + } + + private fun NSError.handleError() { + val playbackError = avPlayer.error?.let { error -> + when (error.domain) { + AVFoundationErrorDomain -> when (error.code) { + AVErrorFormatUnsupported, + AVErrorContentIsNotAuthorized, + AVErrorContentIsProtected, + AVErrorContentIsUnavailable -> PlaybackError.Unsupported + AVErrorDecodeFailed, + AVErrorFailedToParse -> PlaybackError.MalformedMediaSource + AVErrorNoLongerPlayable -> PlaybackError.IO + AVErrorContentNotUpdated -> PlaybackError.TimedOut + else -> null + } + else -> null + } + } ?: PlaybackError.Unknown + handleError(playbackError) + } +} diff --git a/media/src/jsMain/kotlin/DefaultMediaManager.kt b/media/src/jsMain/kotlin/DefaultMediaManager.kt new file mode 100644 index 000000000..6552a255f --- /dev/null +++ b/media/src/jsMain/kotlin/DefaultMediaManager.kt @@ -0,0 +1,55 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration + +actual class PlayableMedia(val url: String) { + actual val duration: Duration get() = Duration.ZERO + actual val currentPlayTime: Duration get() = Duration.ZERO +} + +actual class DefaultMediaManager(coroutineContext: CoroutineContext) : BaseMediaManager(coroutineContext) { + + override fun createPlayableMedia(url: String): PlayableMedia? = PlayableMedia(url) + + override fun initialize(playableMedia: PlayableMedia) { + handlePrepared(playableMedia) + } + + override fun play() { + TODO("Not yet implemented") + } + + override fun pause() { + TODO("Not yet implemented") + } + + override fun stop() { + TODO("Not yet implemented") + } + + override fun seekTo(duration: Duration) { + TODO("Not yet implemented") + } + + override fun cleanUp() { + TODO("Not yet implemented") + } +} diff --git a/media/src/jvmMain/kotlin/DefaultMediaManager.kt b/media/src/jvmMain/kotlin/DefaultMediaManager.kt new file mode 100644 index 000000000..6552a255f --- /dev/null +++ b/media/src/jvmMain/kotlin/DefaultMediaManager.kt @@ -0,0 +1,55 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.media + +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration + +actual class PlayableMedia(val url: String) { + actual val duration: Duration get() = Duration.ZERO + actual val currentPlayTime: Duration get() = Duration.ZERO +} + +actual class DefaultMediaManager(coroutineContext: CoroutineContext) : BaseMediaManager(coroutineContext) { + + override fun createPlayableMedia(url: String): PlayableMedia? = PlayableMedia(url) + + override fun initialize(playableMedia: PlayableMedia) { + handlePrepared(playableMedia) + } + + override fun play() { + TODO("Not yet implemented") + } + + override fun pause() { + TODO("Not yet implemented") + } + + override fun stop() { + TODO("Not yet implemented") + } + + override fun seekTo(duration: Duration) { + TODO("Not yet implemented") + } + + override fun cleanUp() { + TODO("Not yet implemented") + } +} From e2bf085afd392bed7354f651bf83709ee795ff04 Mon Sep 17 00:00:00 2001 From: Gijs van Veen Date: Tue, 21 Mar 2023 16:35:50 +0100 Subject: [PATCH 03/47] Added rudimentary example --- example/android/src/main/AndroidManifest.xml | 3 + .../featurelist/FeaturesListFragment.kt | 2 + .../kaluga/example/media/MediaActivity.kt | 37 +++++++++++ .../src/main/res/layout/activity_media.xml | 36 ++++++++++ .../android/src/main/res/values/strings.xml | 1 + example/ios/Demo.xcodeproj/project.pbxproj | 14 +++- .../ios/Demo/Base.lproj/Localizable.strings | 1 + example/ios/Demo/Base.lproj/Main.storyboard | 65 ++++++++++++++----- .../FeaturesListViewController.swift | 1 + .../ios/Demo/Media/MediaViewController.swift | 39 +++++++++++ example/shared/build.gradle.kts | 1 + .../example/shared/di/DependencyInjection.kt | 7 ++ .../featureList/FeatureListViewModel.kt | 4 ++ .../shared/viewmodel/media/MediaViewModel.kt | 41 ++++++++++++ media/src/commonMain/kotlin/MediaPlayer.kt | 13 ++++ .../src/iosMain/kotlin/DefaultMediaManager.kt | 5 ++ .../src/jsMain/kotlin/DefaultMediaManager.kt | 4 ++ .../src/jvmMain/kotlin/DefaultMediaManager.kt | 4 ++ 18 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 example/android/src/main/java/com/splendo/kaluga/example/media/MediaActivity.kt create mode 100644 example/android/src/main/res/layout/activity_media.xml create mode 100644 example/ios/Demo/Media/MediaViewController.swift create mode 100644 example/shared/src/commonMain/kotlin/com/splendo/kaluga/example/shared/viewmodel/media/MediaViewModel.kt diff --git a/example/android/src/main/AndroidManifest.xml b/example/android/src/main/AndroidManifest.xml index 8b5a06e6f..cd5809388 100644 --- a/example/android/src/main/AndroidManifest.xml +++ b/example/android/src/main/AndroidManifest.xml @@ -140,6 +140,9 @@ + diff --git a/example/android/src/main/java/com/splendo/kaluga/example/featurelist/FeaturesListFragment.kt b/example/android/src/main/java/com/splendo/kaluga/example/featurelist/FeaturesListFragment.kt index d179b36fa..e060c4972 100644 --- a/example/android/src/main/java/com/splendo/kaluga/example/featurelist/FeaturesListFragment.kt +++ b/example/android/src/main/java/com/splendo/kaluga/example/featurelist/FeaturesListFragment.kt @@ -38,6 +38,7 @@ import com.splendo.kaluga.example.keyboard.KeyboardActivity import com.splendo.kaluga.example.link.LinksActivity import com.splendo.kaluga.example.loading.LoadingActivity import com.splendo.kaluga.example.location.LocationActivity +import com.splendo.kaluga.example.media.MediaActivity import com.splendo.kaluga.example.permissions.PermissionsListActivity import com.splendo.kaluga.example.resources.ResourcesActivity import com.splendo.kaluga.example.scientific.ScientificActivity @@ -63,6 +64,7 @@ class FeaturesListFragment : KalugaViewModelFragment() { FeatureListNavigationAction.Architecture -> NavigationSpec.Activity() FeatureListNavigationAction.Keyboard -> NavigationSpec.Activity() FeatureListNavigationAction.Links -> NavigationSpec.Activity() + FeatureListNavigationAction.Media -> NavigationSpec.Activity() FeatureListNavigationAction.System -> NavigationSpec.Activity() FeatureListNavigationAction.Bluetooth -> NavigationSpec.Activity() FeatureListNavigationAction.Beacons -> NavigationSpec.Activity() diff --git a/example/android/src/main/java/com/splendo/kaluga/example/media/MediaActivity.kt b/example/android/src/main/java/com/splendo/kaluga/example/media/MediaActivity.kt new file mode 100644 index 000000000..ea1def00e --- /dev/null +++ b/example/android/src/main/java/com/splendo/kaluga/example/media/MediaActivity.kt @@ -0,0 +1,37 @@ +/* + Copyright 2023 Splendo Consulting B.V. The Netherlands + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + */ + +package com.splendo.kaluga.example.media + +import android.os.Bundle +import com.splendo.kaluga.architecture.viewmodel.KalugaViewModelActivity +import com.splendo.kaluga.example.databinding.ActivityMediaBinding +import com.splendo.kaluga.example.shared.viewmodel.media.MediaViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MediaActivity : KalugaViewModelActivity() { + + override val viewModel: MediaViewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val binding = ActivityMediaBinding.inflate(layoutInflater, null, false) + binding.viewModel = viewModel + binding.lifecycleOwner = this + setContentView(binding.root) + } +} diff --git a/example/android/src/main/res/layout/activity_media.xml b/example/android/src/main/res/layout/activity_media.xml new file mode 100644 index 000000000..2b5324188 --- /dev/null +++ b/example/android/src/main/res/layout/activity_media.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/example/android/src/main/res/values/strings.xml b/example/android/src/main/res/values/strings.xml index ccaebd9b5..f0d39ce32 100644 --- a/example/android/src/main/res/values/strings.xml +++ b/example/android/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Keyboard Links Location + Media Permissions System Beacons diff --git a/example/ios/Demo.xcodeproj/project.pbxproj b/example/ios/Demo.xcodeproj/project.pbxproj index 719f11945..19402bee4 100644 --- a/example/ios/Demo.xcodeproj/project.pbxproj +++ b/example/ios/Demo.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 201EA22529390A7F00C562EC /* ButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201EA22429390A7F00C562EC /* ButtonView.swift */; }; 201EA22729390CFE00C562EC /* LabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201EA22629390CFE00C562EC /* LabelView.swift */; }; 201EA229293B8C6600C562EC /* ColorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201EA228293B8C6600C562EC /* ColorView.swift */; }; + 2025CA2B29C9FC480037DF68 /* MediaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2025CA2A29C9FC480037DF68 /* MediaViewController.swift */; }; 2034C13A2937B494003A6D19 /* PartialSheet in Frameworks */ = {isa = PBXBuildFile; productRef = 2034C1392937B494003A6D19 /* PartialSheet */; }; 20524A24257BEA1E0054B57E /* DateTimePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20524A23257BEA1E0054B57E /* DateTimePickerViewController.swift */; }; 205747572397C9C400CDE25E /* KeyboardManagerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 205747562397C9C400CDE25E /* KeyboardManagerViewController.swift */; }; @@ -141,6 +142,7 @@ 201EA22429390A7F00C562EC /* ButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonView.swift; sourceTree = ""; }; 201EA22629390CFE00C562EC /* LabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelView.swift; sourceTree = ""; }; 201EA228293B8C6600C562EC /* ColorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorView.swift; sourceTree = ""; }; + 2025CA2A29C9FC480037DF68 /* MediaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewController.swift; sourceTree = ""; }; 20524A23257BEA1E0054B57E /* DateTimePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimePickerViewController.swift; sourceTree = ""; }; 205747562397C9C400CDE25E /* KeyboardManagerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardManagerViewController.swift; sourceTree = ""; }; 205AF6552488F017001BD99E /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Demo/Base.lproj/Localizable.strings; sourceTree = ""; }; @@ -214,7 +216,7 @@ 896B16F89BA730B7FBF42625 /* RoutingState.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = RoutingState.generated.swift; sourceTree = ""; }; 89DAFF461D97215C4B63A0A1 /* PlatformMappers.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = PlatformMappers.generated.swift; sourceTree = ""; }; 93070CB7E1723F904DBEDD26 /* DefaultValues.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = DefaultValues.generated.swift; sourceTree = ""; }; - 944D74BABB71F3B76E746EC8 /* TintedImage+Image.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; lastKnownFileType = sourcecode.swift; name = "TintedImage+Image.generated.swift"; path = "TintedImage+Image.generated.swift"; sourceTree = ""; }; + 944D74BABB71F3B76E746EC8 /* TintedImage+Image.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = "TintedImage+Image.generated.swift"; sourceTree = ""; }; 978348A6235DF586005B140B /* PermissionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionViewController.swift; sourceTree = ""; }; A1AD210B87A46DBD057A7D28 /* Navigation.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = Navigation.generated.swift; sourceTree = ""; }; A8678F48E60D40E6AB06177C /* LifecycleViewModel.generated.swift */ = {isa = PBXFileReference; explicitFileType = sourcecode.swift; path = LifecycleViewModel.generated.swift; sourceTree = ""; }; @@ -257,6 +259,14 @@ path = UITypeSelection; sourceTree = ""; }; + 2025CA2929C9FC310037DF68 /* Media */ = { + isa = PBXGroup; + children = ( + 2025CA2A29C9FC480037DF68 /* MediaViewController.swift */, + ); + path = Media; + sourceTree = ""; + }; 20524A22257BEA020054B57E /* DateTimePicker */ = { isa = PBXGroup; children = ( @@ -450,6 +460,7 @@ 746CAD9D15ECB24F4ED84E11 /* Demo */ = { isa = PBXGroup; children = ( + 2025CA2929C9FC310037DF68 /* Media */, 20D4E17A296D561500A046D6 /* Scientific */, 20BB9E7829645A8E0096E836 /* DateTime */, 201EA2172937C2CC00C562EC /* UITypeSelection */, @@ -785,6 +796,7 @@ 207537AB29A64D3000D273F2 /* ImagesViewController.swift in Sources */, 42B12B5CFE87189A5F539B92 /* HasDefaultValue.generated.swift in Sources */, D174F54A364898AC7E0962FC /* KalugaBackgroundStyle+SwiftUI.generated.swift in Sources */, + 2025CA2B29C9FC480037DF68 /* MediaViewController.swift in Sources */, 20FFB48C293EC3CB005CF1C8 /* ArchitectureDetailsView.swift in Sources */, D976FB8DC34A35578A995B2E /* KalugaButton+SwiftUI.generated.swift in Sources */, 7C49888336FE57AAA89E7958 /* KalugaDate+Extensions.generated.swift in Sources */, diff --git a/example/ios/Demo/Base.lproj/Localizable.strings b/example/ios/Demo/Base.lproj/Localizable.strings index a302edf3a..7d6ce4b38 100644 --- a/example/ios/Demo/Base.lproj/Localizable.strings +++ b/example/ios/Demo/Base.lproj/Localizable.strings @@ -8,6 +8,7 @@ "feature_hud" = "Loading Indicator"; "feature_keyboard" = "Keyboard"; "feature_location" = "Location"; +"feature_media" = "Media"; "feature_permissions" = "Permissions"; "feature_links" = "Links"; "feature_beacons" = "Beacons"; diff --git a/example/ios/Demo/Base.lproj/Main.storyboard b/example/ios/Demo/Base.lproj/Main.storyboard index be3b25674..ffba1756f 100644 --- a/example/ios/Demo/Base.lproj/Main.storyboard +++ b/example/ios/Demo/Base.lproj/Main.storyboard @@ -354,14 +354,14 @@ - + - +