mirror of
https://github.com/AntennaPod/AntennaPod.git
synced 2026-08-18 11:05:49 +00:00
Basic WearOS app (#8396)
### Description An initial minimal implementation for a Wear OS app. So far, the wear app can only display episodes and start them on the phone. No standalone features available yet. Contributes to #4264 ### Checklist <!-- To help us keep the issue tracker clean and work as efficient as possible, please make sure that you have done all of the following. You can tick the boxes below by placing an x inside the brackets like this: [x] --> - [x] I have read the contribution guidelines: https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request - [x] I have performed a self-review of my code, going through my changes line by line and carefully considering why this line change is necessary - [x] I have run the automated code checks using `./gradlew checkstyle lint spotbugsPlayDebug spotbugsDebug` - [x] My code follows the style guidelines of the AntennaPod project: https://antennapod.org/contribute/develop/app/code-style - [x] I have mentioned the corresponding issue and the relevant keyword (e.g., "Closes: #xy") in the description (see https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) - [x] If it is a core feature, I have added automated tests
This commit is contained in:
committed by
GitHub
parent
c39cfc9719
commit
5fe70196eb
4
app-wearos/README.md
Normal file
4
app-wearos/README.md
Normal file
@ -0,0 +1,4 @@
|
||||
# :app-wearos
|
||||
|
||||
A companion Wear OS app for AntennaPod. Can browse episodes from the phone and remote-control the phone to start playback.
|
||||
Communicates with the phone via the WearOS DataLayer API using paths and serialization defined in `:net:sync:wear-interface`.
|
||||
83
app-wearos/build.gradle
Normal file
83
app-wearos/build.gradle
Normal file
@ -0,0 +1,83 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
}
|
||||
apply from: "../common.gradle"
|
||||
apply from: "../playFlavor.gradle"
|
||||
|
||||
android {
|
||||
namespace "de.danoeh.antennapod.wearos"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "de.danoeh.antennapod"
|
||||
minSdk 25
|
||||
targetSdk 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
releaseConfig {
|
||||
enableV1Signing true
|
||||
enableV2Signing true
|
||||
storeFile file(project.getProperties().getOrDefault("releaseStoreFile", "keystore"))
|
||||
storePassword project.getProperties().getOrDefault("releaseStorePassword", "password")
|
||||
keyAlias project.getProperties().getOrDefault("releaseKeyAlias", "alias")
|
||||
keyPassword project.getProperties().getOrDefault("releaseKeyPassword", "password")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
signingConfig signingConfigs.releaseConfig
|
||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard.cfg"
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "21"
|
||||
}
|
||||
|
||||
lint {
|
||||
disable "UnusedResources"
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named('lint').configure {
|
||||
dependsOn ktlintCheck
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(":model")
|
||||
implementation project(":ui:common")
|
||||
implementation project(":net:sync:wear-interface")
|
||||
|
||||
// NOTE: DO NOT INCLUDE a dependency on androidx.compose.material:material, the watch version is a replacement
|
||||
implementation "androidx.wear:wear:$wearVersion"
|
||||
implementation "com.google.android.gms:play-services-wearable:$wearableVersion"
|
||||
implementation "androidx.wear.compose:compose-foundation:$wearComposeVersion"
|
||||
implementation "androidx.wear.compose:compose-material3:$wearComposeVersion"
|
||||
implementation "androidx.compose.ui:ui:$composeVersion"
|
||||
implementation "androidx.compose.runtime:runtime:$composeVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleRuntimeComposeVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleRuntimeComposeVersion"
|
||||
implementation "androidx.core:core-ktx:$coreKtxVersion"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0"
|
||||
implementation "androidx.activity:activity-compose:$activityComposeVersion"
|
||||
implementation "androidx.appcompat:appcompat:$appcompatVersion"
|
||||
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
}
|
||||
11
app-wearos/proguard.cfg
Normal file
11
app-wearos/proguard.cfg
Normal file
@ -0,0 +1,11 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
|
||||
# Keep Kotlin metadata
|
||||
-keep class kotlin.metadata.** { *; }
|
||||
|
||||
# Keep Compose runtime
|
||||
-keep class androidx.compose.runtime.** { *; }
|
||||
|
||||
# Keep Activity
|
||||
-keep class androidx.activity.** { *; }
|
||||
-keep class androidx.activity.compose.** { *; }
|
||||
57
app-wearos/src/main/AndroidManifest.xml
Normal file
57
app-wearos/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature android:name="android.hardware.type.watch" android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@android:style/Theme.DeviceDefault">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.standalone"
|
||||
android:value="false" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
android:value="@integer/google_play_services_version" />
|
||||
|
||||
<activity
|
||||
android:name="de.danoeh.antennapod.wearos.MainListActivity"
|
||||
android:exported="true"
|
||||
android:taskAffinity=".wearos">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="de.danoeh.antennapod.wearos.FeedListActivity"
|
||||
android:exported="false"
|
||||
android:taskAffinity=".wearos" />
|
||||
|
||||
<activity
|
||||
android:name="de.danoeh.antennapod.wearos.EpisodeListActivity"
|
||||
android:exported="false"
|
||||
android:taskAffinity=".wearos" />
|
||||
|
||||
<activity
|
||||
android:name="de.danoeh.antennapod.wearos.EpisodeDetailActivity"
|
||||
android:exported="false"
|
||||
android:taskAffinity=".wearos" />
|
||||
|
||||
<service
|
||||
android:name="de.danoeh.antennapod.wearos.sync.WearDataListenerService"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
|
||||
<data android:scheme="wear" android:host="*" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -0,0 +1,178 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.Hyphens
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import androidx.wear.compose.material3.LinearProgressIndicator
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScrollIndicator
|
||||
import androidx.wear.compose.material3.Text
|
||||
import de.danoeh.antennapod.model.feed.FeedItem
|
||||
import de.danoeh.antennapod.ui.common.Converter
|
||||
import de.danoeh.antennapod.ui.common.DateFormatter
|
||||
import de.danoeh.antennapod.ui.common.R as CommonR
|
||||
import de.danoeh.antennapod.wearos.composable.ListItem
|
||||
|
||||
class EpisodeDetailActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val episode = IntentCompat.getSerializableExtra(intent, EXTRA_EPISODE, FeedItem::class.java) ?: run {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val viewModel = ViewModelProvider(this, EpisodeDetailViewModel.factory(episode))
|
||||
.get(EpisodeDetailViewModel::class.java)
|
||||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
EpisodeDetailScreen(
|
||||
uiState = uiState,
|
||||
onPlay = { viewModel.play() },
|
||||
onPause = { viewModel.pause() },
|
||||
onOpenOnPhone = { viewModel.openOnPhone() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_EPISODE = "episode"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EpisodeDetailScreen(
|
||||
uiState: EpisodeDetailUiState,
|
||||
onPlay: () -> Unit,
|
||||
onPause: () -> Unit,
|
||||
onOpenOnPhone: () -> Unit
|
||||
) {
|
||||
val item = uiState.item
|
||||
val scrollState = rememberScalingLazyListState()
|
||||
var titleExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = scrollState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 32.dp),
|
||||
autoCentering = null
|
||||
) {
|
||||
item {
|
||||
val dateStr = DateFormatter.formatAbbrev(LocalContext.current, item.getPubDate())
|
||||
Text(
|
||||
text = dateStr,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = item.title,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clickable { titleExpanded = !titleExpanded },
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
hyphens = Hyphens.Auto,
|
||||
lineBreak = LineBreak.Paragraph
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = if (titleExpanded) Int.MAX_VALUE else 3,
|
||||
overflow = if (titleExpanded) TextOverflow.Clip else TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.duration > 0) {
|
||||
item {
|
||||
LinearProgressIndicator(
|
||||
progress = { uiState.position.toFloat() / uiState.duration.toFloat() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, top = 2.dp, end = 8.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = Converter.getDurationStringLong(uiState.position),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = Converter.getDurationStringLong(uiState.duration),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (uiState.isCurrentlyPlaying) {
|
||||
ListItem(
|
||||
text = stringResource(CommonR.string.pause_label),
|
||||
iconRes = CommonR.drawable.ic_pause_black,
|
||||
onClick = onPause
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
text = stringResource(CommonR.string.wearos_play_on_phone),
|
||||
iconRes = CommonR.drawable.ic_play_48dp_black,
|
||||
onClick = onPlay
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ListItem(
|
||||
text = stringResource(CommonR.string.wearos_open_on_phone),
|
||||
iconRes = CommonR.drawable.ic_phone_black,
|
||||
onClick = onOpenOnPhone
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
ScrollIndicator(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.danoeh.antennapod.model.feed.FeedItem
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.wearos.sync.WearDataRepository
|
||||
import de.danoeh.antennapod.wearos.sync.WearMessageSender
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class EpisodeDetailUiState(
|
||||
val item: FeedItem,
|
||||
val position: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val isCurrentlyPlaying: Boolean = false
|
||||
)
|
||||
|
||||
class EpisodeDetailViewModel(application: Application, private val episode: FeedItem) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(
|
||||
EpisodeDetailUiState(
|
||||
item = episode,
|
||||
position = episode.media?.position ?: 0,
|
||||
duration = episode.media?.duration ?: 0
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<EpisodeDetailUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
WearMessageSender.send(getApplication(), WearDataPaths.NOW_PLAYING)
|
||||
delay(1.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
WearDataRepository.nowPlaying.collect { nowPlaying ->
|
||||
val liveData = nowPlaying?.takeIf { it.item.id == episode.id }
|
||||
val position = liveData?.item?.media?.position ?: episode.media?.position ?: 0
|
||||
val duration = liveData?.item?.media?.duration?.takeIf { it > 0 } ?: episode.media?.duration ?: 0
|
||||
val isCurrentlyPlaying = liveData?.isPlaying == true
|
||||
_uiState.update {
|
||||
it.copy(position = position, duration = duration, isCurrentlyPlaying = isCurrentlyPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
WearMessageSender.send(getApplication(), WearDataPaths.playPath(episode.id))
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
viewModelScope.launch(Dispatchers.IO) { WearMessageSender.send(getApplication(), WearDataPaths.PAUSE) }
|
||||
}
|
||||
|
||||
fun openOnPhone() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
WearMessageSender.send(getApplication(), WearDataPaths.openOnPhonePath(episode.id))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(episode: FeedItem): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
EpisodeDetailViewModel(
|
||||
checkNotNull(get(ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY)),
|
||||
episode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import de.danoeh.antennapod.model.feed.FeedItem
|
||||
import de.danoeh.antennapod.wearos.composable.ListItem
|
||||
import de.danoeh.antennapod.wearos.composable.ListScaffold
|
||||
|
||||
class EpisodeListActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val path = intent.getStringExtra(EXTRA_PATH) ?: run {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val viewModel = ViewModelProvider(this, EpisodeListViewModel.factory(path))[EpisodeListViewModel::class.java]
|
||||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
EpisodeListScreen(
|
||||
uiState = uiState,
|
||||
onOpenEpisodeDetail = { episode ->
|
||||
val intent = Intent(this, EpisodeDetailActivity::class.java).apply {
|
||||
putExtra(EpisodeDetailActivity.EXTRA_EPISODE, episode)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_PATH = "path"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EpisodeListScreen(uiState: EpisodeListUiState, onOpenEpisodeDetail: (FeedItem) -> Unit) {
|
||||
val scrollState = rememberScalingLazyListState()
|
||||
val episodes: List<FeedItem>? = uiState.episodes
|
||||
|
||||
ListScaffold(
|
||||
titleRes = uiState.titleRes,
|
||||
scrollState = scrollState,
|
||||
isPhoneSupported = uiState.isPhoneSupported,
|
||||
isTimedOut = uiState.isTimedOut,
|
||||
isLoading = episodes == null,
|
||||
isEmpty = episodes?.isEmpty() == true
|
||||
) {
|
||||
items(episodes!!) { episode ->
|
||||
ListItem(
|
||||
text = episode.title ?: "",
|
||||
onClick = { onOpenEpisodeDetail(episode) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import de.danoeh.antennapod.model.feed.FeedItem
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearConnectionUtils
|
||||
import de.danoeh.antennapod.wearos.sync.WearDataRepository
|
||||
import de.danoeh.antennapod.wearos.sync.WearMessageSender
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
data class EpisodeListUiState(
|
||||
val episodes: List<FeedItem>? = null,
|
||||
val isPhoneSupported: Boolean = true,
|
||||
val isTimedOut: Boolean = false,
|
||||
val titleRes: Int = 0
|
||||
)
|
||||
|
||||
class EpisodeListViewModel(application: Application, private val path: String) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(EpisodeListUiState(titleRes = NavigationNames.getTitleResForPath(path)))
|
||||
val uiState: StateFlow<EpisodeListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
WearDataRepository.episodesByPath.collect { map ->
|
||||
_uiState.update { it.copy(episodes = map[path]) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val isSupported = withContext(Dispatchers.IO) {
|
||||
WearConnectionUtils.isPhoneSupported(getApplication())
|
||||
}
|
||||
if (!isSupported) {
|
||||
_uiState.update { it.copy(isPhoneSupported = false) }
|
||||
return@launch
|
||||
}
|
||||
WearMessageSender.send(getApplication(), path)
|
||||
val received = withTimeoutOrNull(10.seconds) {
|
||||
WearDataRepository.episodesByPath.first { it.containsKey(path) }
|
||||
}
|
||||
if (received == null) {
|
||||
_uiState.update { it.copy(isTimedOut = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(path: String): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
EpisodeListViewModel(checkNotNull(get(ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY)), path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import de.danoeh.antennapod.model.feed.Feed
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.wearos.composable.ListItem
|
||||
import de.danoeh.antennapod.wearos.composable.ListScaffold
|
||||
|
||||
class FeedListActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val viewModel = ViewModelProvider(this)[FeedListViewModel::class.java]
|
||||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
FeedListScreen(uiState = uiState, onOpenFeedEpisodes = { feedId ->
|
||||
val intent = Intent(this, EpisodeListActivity::class.java).apply {
|
||||
putExtra(EpisodeListActivity.EXTRA_PATH, WearDataPaths.feedEpisodesPath(feedId))
|
||||
}
|
||||
startActivity(intent)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedListScreen(uiState: FeedListUiState, onOpenFeedEpisodes: (Long) -> Unit) {
|
||||
val scrollState = rememberScalingLazyListState()
|
||||
val feeds: List<Feed>? = uiState.feeds
|
||||
|
||||
ListScaffold(
|
||||
titleRes = NavigationNames.getTitleResForPath(WearDataPaths.SUBSCRIPTIONS),
|
||||
scrollState = scrollState,
|
||||
isPhoneSupported = uiState.isPhoneSupported,
|
||||
isTimedOut = uiState.isTimedOut,
|
||||
isLoading = feeds == null,
|
||||
isEmpty = feeds?.isEmpty() == true
|
||||
) {
|
||||
items(feeds!!) { feed ->
|
||||
ListItem(
|
||||
text = feed.title ?: "",
|
||||
onClick = { onOpenFeedEpisodes(feed.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.danoeh.antennapod.model.feed.Feed
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearConnectionUtils
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.wearos.sync.WearDataRepository
|
||||
import de.danoeh.antennapod.wearos.sync.WearMessageSender
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
data class FeedListUiState(
|
||||
val feeds: List<Feed>? = null,
|
||||
val isPhoneSupported: Boolean = true,
|
||||
val isTimedOut: Boolean = false
|
||||
)
|
||||
|
||||
class FeedListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(FeedListUiState())
|
||||
val uiState: StateFlow<FeedListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
WearDataRepository.feedsByPath.collect { map ->
|
||||
_uiState.update { it.copy(feeds = map[WearDataPaths.SUBSCRIPTIONS]) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val isSupported = withContext(Dispatchers.IO) {
|
||||
WearConnectionUtils.isPhoneSupported(getApplication())
|
||||
}
|
||||
if (!isSupported) {
|
||||
_uiState.update { it.copy(isPhoneSupported = false) }
|
||||
return@launch
|
||||
}
|
||||
WearMessageSender.send(getApplication(), WearDataPaths.SUBSCRIPTIONS)
|
||||
val received = withTimeoutOrNull(10.seconds) {
|
||||
WearDataRepository.feedsByPath.first { it.containsKey(WearDataPaths.SUBSCRIPTIONS) }
|
||||
}
|
||||
if (received == null) {
|
||||
_uiState.update { it.copy(isTimedOut = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import androidx.wear.compose.material3.ListHeader
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScrollIndicator
|
||||
import androidx.wear.compose.material3.Text
|
||||
import de.danoeh.antennapod.model.feed.FeedItem
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.ui.common.R as CommonR
|
||||
import de.danoeh.antennapod.wearos.composable.ListItem
|
||||
|
||||
class MainListActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val viewModel = ViewModelProvider(this)[MainViewModel::class.java]
|
||||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
MainListScreen(
|
||||
uiState = uiState,
|
||||
onOpenEpisodeDetail = { episode ->
|
||||
val intent = Intent(this, EpisodeDetailActivity::class.java).apply {
|
||||
putExtra(EpisodeDetailActivity.EXTRA_EPISODE, episode)
|
||||
}
|
||||
startActivity(intent)
|
||||
},
|
||||
onOpenFeedList = {
|
||||
startActivity(Intent(this, FeedListActivity::class.java))
|
||||
},
|
||||
onOpenEpisodeList = { path ->
|
||||
val intent = Intent(this, EpisodeListActivity::class.java).apply {
|
||||
putExtra(EpisodeListActivity.EXTRA_PATH, path)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainListScreen(
|
||||
uiState: MainUiState,
|
||||
onOpenEpisodeDetail: (FeedItem) -> Unit,
|
||||
onOpenFeedList: () -> Unit,
|
||||
onOpenEpisodeList: (String) -> Unit
|
||||
) {
|
||||
val scrollState = rememberScalingLazyListState()
|
||||
val versionName = BuildConfig.VERSION_NAME
|
||||
|
||||
val menuItems = remember {
|
||||
listOf(
|
||||
WearDataPaths.QUEUE,
|
||||
WearDataPaths.DOWNLOADS,
|
||||
WearDataPaths.EPISODES,
|
||||
WearDataPaths.SUBSCRIPTIONS
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = scrollState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
ListHeader(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.app_name))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (uiState.nowPlaying != null) {
|
||||
ListItem(
|
||||
text = uiState.nowPlaying.item.title ?: "",
|
||||
onClick = { onOpenEpisodeDetail(uiState.nowPlaying.item) }
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
text = stringResource(CommonR.string.no_media_playing_label),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(CommonR.string.wearos_browse_phone),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
items(menuItems) { path ->
|
||||
ListItem(
|
||||
text = stringResource(NavigationNames.getTitleResForPath(path)),
|
||||
iconRes = NavigationNames.getIconResForPath(path),
|
||||
onClick = {
|
||||
if (path == WearDataPaths.SUBSCRIPTIONS) {
|
||||
onOpenFeedList()
|
||||
} else {
|
||||
onOpenEpisodeList(path)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val connectionText = if (uiState.connectedPhone.isNotEmpty()) {
|
||||
stringResource(CommonR.string.wearos_connected_to, uiState.connectedPhone) + "\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
Text(
|
||||
text = connectionText + stringResource(CommonR.string.wearos_version, versionName),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
ScrollIndicator(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearConnectionUtils
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearNowPlaying
|
||||
import de.danoeh.antennapod.wearos.sync.WearDataRepository
|
||||
import de.danoeh.antennapod.wearos.sync.WearMessageSender
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MainUiState(val connectedPhone: String = "", val nowPlaying: WearNowPlaying? = null)
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(MainUiState())
|
||||
val uiState: StateFlow<MainUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val name = WearConnectionUtils.getConnectedNodeName(getApplication())
|
||||
_uiState.update { it.copy(connectedPhone = name) }
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
WearMessageSender.send(getApplication(), WearDataPaths.NOW_PLAYING)
|
||||
delay(5.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
WearDataRepository.nowPlaying.collect { nowPlaying ->
|
||||
_uiState.update { it.copy(nowPlaying = nowPlaying) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package de.danoeh.antennapod.wearos
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.ui.common.R as CommonR
|
||||
|
||||
object NavigationNames {
|
||||
@StringRes
|
||||
fun getTitleResForPath(path: String): Int = when (path) {
|
||||
WearDataPaths.QUEUE -> CommonR.string.queue_label
|
||||
WearDataPaths.DOWNLOADS -> CommonR.string.downloads_label
|
||||
WearDataPaths.EPISODES -> CommonR.string.episodes_label
|
||||
WearDataPaths.SUBSCRIPTIONS -> CommonR.string.subscriptions_label
|
||||
else -> if (path.startsWith(WearDataPaths.FEED_EPISODES_PREFIX)) {
|
||||
CommonR.string.episodes_label
|
||||
} else {
|
||||
CommonR.string.app_name
|
||||
}
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun getIconResForPath(path: String): Int = when (path) {
|
||||
WearDataPaths.QUEUE -> CommonR.drawable.ic_playlist_play_black
|
||||
WearDataPaths.DOWNLOADS -> CommonR.drawable.ic_download_black
|
||||
WearDataPaths.EPISODES -> CommonR.drawable.ic_feed_black
|
||||
WearDataPaths.SUBSCRIPTIONS -> CommonR.drawable.ic_subscriptions_black
|
||||
else -> CommonR.mipmap.ic_launcher
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package de.danoeh.antennapod.wearos.composable
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.material3.FilledTonalButton
|
||||
import androidx.wear.compose.material3.Icon
|
||||
import androidx.wear.compose.material3.Text
|
||||
|
||||
@Composable
|
||||
fun ListItem(text: String, @DrawableRes iconRes: Int? = null, onClick: () -> Unit) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (iconRes != null) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(text = text, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package de.danoeh.antennapod.wearos.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
|
||||
import androidx.wear.compose.material3.CircularProgressIndicator
|
||||
import androidx.wear.compose.material3.ListHeader
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.ScrollIndicator
|
||||
import androidx.wear.compose.material3.Text
|
||||
import de.danoeh.antennapod.ui.common.R as CommonR
|
||||
|
||||
@Composable
|
||||
fun ListScaffold(
|
||||
titleRes: Int,
|
||||
scrollState: ScalingLazyListState,
|
||||
isPhoneSupported: Boolean,
|
||||
isTimedOut: Boolean,
|
||||
isLoading: Boolean,
|
||||
isEmpty: Boolean,
|
||||
content: ScalingLazyListScope.() -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScalingLazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
state = scrollState
|
||||
) {
|
||||
item {
|
||||
ListHeader(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(titleRes))
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
!isPhoneSupported -> item {
|
||||
Text(
|
||||
text = stringResource(CommonR.string.wearos_phone_not_supported),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
!isLoading && !isEmpty -> content()
|
||||
|
||||
isTimedOut -> item {
|
||||
Text(
|
||||
text = stringResource(CommonR.string.wearos_phone_not_reachable),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
isLoading -> item { CircularProgressIndicator() }
|
||||
|
||||
else -> item { Text(stringResource(CommonR.string.wearos_no_items)) }
|
||||
}
|
||||
}
|
||||
ScrollIndicator(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package de.danoeh.antennapod.wearos.sync
|
||||
|
||||
import android.util.Log
|
||||
import com.google.android.gms.wearable.MessageEvent
|
||||
import com.google.android.gms.wearable.WearableListenerService
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearDataPaths
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearSerializer
|
||||
|
||||
class WearDataListenerService : WearableListenerService() {
|
||||
override fun onMessageReceived(event: MessageEvent) {
|
||||
val path = event.path
|
||||
when (path) {
|
||||
WearDataPaths.SUBSCRIPTIONS -> {
|
||||
val feeds = WearSerializer.feedsFromBytes(event.data)
|
||||
Log.d(TAG, "Received $path: ${feeds.size} feeds")
|
||||
WearDataRepository.updateFeeds(path, feeds)
|
||||
}
|
||||
WearDataPaths.NOW_PLAYING ->
|
||||
WearDataRepository.updateNowPlaying(WearSerializer.nowPlayingFromBytes(event.data))
|
||||
WearDataPaths.QUEUE, WearDataPaths.DOWNLOADS, WearDataPaths.EPISODES -> {
|
||||
val items = WearSerializer.episodesFromBytes(event.data)
|
||||
Log.d(TAG, "Received $path: ${items.size} episodes")
|
||||
WearDataRepository.updateEpisodes(path, items)
|
||||
}
|
||||
else -> if (path.startsWith(WearDataPaths.FEED_EPISODES_PREFIX)) {
|
||||
val items = WearSerializer.episodesFromBytes(event.data)
|
||||
Log.d(TAG, "Received $path: ${items.size} episodes")
|
||||
WearDataRepository.updateEpisodes(path, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WearDataListenerService"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package de.danoeh.antennapod.wearos.sync
|
||||
|
||||
import de.danoeh.antennapod.model.feed.Feed
|
||||
import de.danoeh.antennapod.model.feed.FeedItem
|
||||
import de.danoeh.antennapod.net.sync.wearinterface.WearNowPlaying
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Singleton holding the last data received from the phone for each data path.
|
||||
* Written by WearDataListenerService (even when no activity is running) and
|
||||
* read by the screen composables.
|
||||
*/
|
||||
object WearDataRepository {
|
||||
private val _episodesByPath = MutableStateFlow<Map<String, List<FeedItem>>>(emptyMap())
|
||||
val episodesByPath: StateFlow<Map<String, List<FeedItem>>> = _episodesByPath
|
||||
|
||||
private val _feedsByPath = MutableStateFlow<Map<String, List<Feed>>>(emptyMap())
|
||||
val feedsByPath: StateFlow<Map<String, List<Feed>>> = _feedsByPath
|
||||
|
||||
private val _nowPlaying = MutableStateFlow<WearNowPlaying?>(null)
|
||||
val nowPlaying: StateFlow<WearNowPlaying?> = _nowPlaying
|
||||
|
||||
fun updateNowPlaying(nowPlaying: WearNowPlaying?) {
|
||||
_nowPlaying.value = nowPlaying
|
||||
}
|
||||
|
||||
fun updateEpisodes(path: String, items: List<FeedItem>) {
|
||||
_episodesByPath.update { it + (path to items) }
|
||||
}
|
||||
|
||||
fun updateFeeds(path: String, feeds: List<Feed>) {
|
||||
_feedsByPath.update { it + (path to feeds) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package de.danoeh.antennapod.wearos.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
object WearMessageSender {
|
||||
private const val TAG = "WearMessageSender"
|
||||
|
||||
suspend fun send(context: Context, path: String) {
|
||||
try {
|
||||
val nodes = Wearable.getNodeClient(context).connectedNodes.await()
|
||||
val node = nodes.firstOrNull() ?: run {
|
||||
Log.w(TAG, "No connected nodes")
|
||||
return
|
||||
}
|
||||
Wearable.getMessageClient(context).sendMessage(node.id, path, null).await()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send message to phone", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app-wearos/src/main/res/values/wear.xml
Normal file
7
app-wearos/src/main/res/values/wear.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@array/android_wear_capabilities">
|
||||
<string-array name="android_wear_capabilities">
|
||||
<item tools:ignore="Typos">antennapod_watch_v1</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
4
app-wearos/src/main/res/xml/backup_rules.xml
Normal file
4
app-wearos/src/main/res/xml/backup_rules.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="root" path="." />
|
||||
</full-backup-content>
|
||||
9
app-wearos/src/main/res/xml/data_extraction_rules.xml
Normal file
9
app-wearos/src/main/res/xml/data_extraction_rules.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in New Issue
Block a user