diff --git a/README.md b/README.md index 54b75fee..b7a6fbb9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Currently Dicio answers questions about: - **navigation**: opens the navigation app at the requested position - _Take me to New York, fifteenth avenue_ - **jokes**: tells you a joke - _Tell me a joke_ - **media**: play, pause, previous, next song +- **music**: search for and play a specific song - **translation**: translate from/to any language with **Lingva** - _How do I say Football in German?_ - **wake word control**: turn on/off the wakeword - _Stop listening_ - **notifications**: reads all notifications currently in the status bar - _What are my notifications?_ diff --git a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt index 5c7c7f88..8640e25a 100644 --- a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt +++ b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt @@ -33,6 +33,7 @@ import org.stypox.dicio.skills.translation.TranslationInfo import org.stypox.dicio.skills.weather.WeatherInfo import org.stypox.dicio.skills.joke.JokeInfo import org.stypox.dicio.skills.flashlight.FlashlightInfo +import org.stypox.dicio.skills.music.MusicInfo import javax.inject.Inject import javax.inject.Singleton @@ -54,6 +55,7 @@ class SkillHandler @Inject constructor( TimerInfo, CurrentTimeInfo, MediaInfo, + MusicInfo, JokeInfo, ListeningInfo(dataStore), TranslationInfo, diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicInfo.kt new file mode 100644 index 00000000..86704fcd --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicInfo.kt @@ -0,0 +1,39 @@ +package org.stypox.dicio.skills.music + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.Directions +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.fragment.app.Fragment +import org.dicio.skill.skill.Skill +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.stypox.dicio.R +import org.stypox.dicio.sentences.Sentences +import org.stypox.dicio.skills.open.OpenSkill + +object MusicInfo : SkillInfo("music") { + override fun name(context: Context) = + context.getString(R.string.skill_name_music) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_music) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.AutoMirrored.Filled.QueueMusic) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.Music[ctx.sentencesLanguage] != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return MusicSkill(MusicInfo, Sentences.Music[ctx.sentencesLanguage]!!) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicOutput.kt new file mode 100644 index 00000000..02343ca1 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicOutput.kt @@ -0,0 +1,97 @@ +package org.stypox.dicio.skills.music + +import android.content.pm.PackageManager +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillOutput +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.Headline +import org.stypox.dicio.util.getString + +private val TAG = MusicOutput::class.simpleName + +class MusicOutput( + private val appName: String?, + private val packageName: String?, + private val songName: String?, +) : SkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = if (packageName == null) { + ctx.getString(R.string.skill_music_no_app_found) + } else { + ctx.getString(R.string.skill_music_playing, songName, appName) + } + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + if (appName == null || packageName == null) { + Headline(text = getSpeechOutput(ctx)) + + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current + val icon = remember { + try { + context.packageManager.getApplicationIcon(packageName) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "Could not load icon for $packageName", e) + null + } + } + + if (icon != null) { + BoxWithConstraints { + Image( + painter = rememberDrawablePainter(icon), + contentDescription = appName, + modifier = Modifier + .requiredWidth(minOf(maxWidth * 0.2f, 80.dp)) + .aspectRatio(1.0f), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getSpeechOutput(ctx), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = packageName, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicSkill.kt new file mode 100644 index 00000000..83358235 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/music/MusicSkill.kt @@ -0,0 +1,50 @@ +package org.stypox.dicio.skills.music + +import android.app.SearchManager +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.MediaStore +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.StandardRecognizerData +import org.dicio.skill.standard.StandardRecognizerSkill +import org.stypox.dicio.sentences.Sentences.Music + +class MusicSkill(correspondingSkillInfo: SkillInfo, data: StandardRecognizerData) : + StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: Music): SkillOutput { + val (song, artist) = when (inputData) { + is Music.Query -> Pair(inputData.song, inputData.artist) + } + + // https://developer.android.com/guide/components/intents-common#PlaySearch + val intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply { + putExtra(MediaStore.EXTRA_MEDIA_FOCUS, MediaStore.Audio.Media.ENTRY_CONTENT_TYPE) + putExtra(MediaStore.EXTRA_MEDIA_TITLE, song) + + if (artist != null) { + putExtra(MediaStore.EXTRA_MEDIA_ARTIST, artist) + putExtra(SearchManager.QUERY, "$artist $song") + } else { + putExtra(SearchManager.QUERY, song) + } + } + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + val packageManager: PackageManager = ctx.android.packageManager + val componentName = intent.resolveActivity(packageManager) + if (componentName == null) { + return MusicOutput(appName = null, packageName = null, songName = null) + } + ctx.android.startActivity(intent) + + val applicationInfo = packageManager.getApplicationInfo(componentName.packageName, 0) + return MusicOutput( + appName = applicationInfo.loadLabel(packageManager).toString(), + packageName = applicationInfo.packageName, + songName = song + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea8db666..aafa747d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -278,4 +278,8 @@ Privacy policy Dicio connects to external services only when a skill is expected to do so, or to download machine learning models during setup. All speech processing is performed locally on-device, and most skills can be used offline. https://stypox.org/dicio-privacy-policy.html + No music player found + Playing %1$s on %2$s + Play music + Play We will rock you by Queen diff --git a/app/src/main/sentences/en/music.yml b/app/src/main/sentences/en/music.yml new file mode 100644 index 00000000..5efbadec --- /dev/null +++ b/app/src/main/sentences/en/music.yml @@ -0,0 +1,3 @@ +query: + - play .song. (by .artist.)? + - start playing .song. (by .artist.)? diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index ed347e96..c599b4ec 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -37,6 +37,16 @@ skills: - id: where type: string + - id: music + specificity: high + sentences: + - id: query + captures: + - id: song + type: string + - id: artist + type: string + - id: media specificity: high sentences: diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 0542a894..554ec8bd 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -13,6 +13,7 @@ Dicio answers questions about:
  • navigation: opens the navigation app at the requested position - Take me to New York, fifteenth avenue
  • jokes: tells you a joke - Tell me a joke
  • media: play, pause, previous, next song - Next Song
  • +
  • music: search for and play a specific song - Play We will rock you by Queen
  • translation: translate from/to any language with Lingva - How do I say Football in German?
  • wake word control: turn on/off the wakeword - Stop listening
  • notifications: reads all notifications currently in the status bar - What are my notifications?