mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
new supported apps, redesign UI for apps not fully supported
This commit is contained in:
1 parent
4e4a246a51
commit
3994c9bd2e
6 files changed
+81
-14
No files matched your search
@@ -45,7 +45,19 @@ class ScreenProjector(private val context: Context) {
|
|||||||
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
||||||
private var downTime = 0L
|
private var downTime = 0L
|
||||||
|
|
||||||
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
|
/**
|
||||||
|
* [name] names the virtual display so the two callers are told apart from outside the app
|
||||||
|
* (`dumpsys display`): the real car session keeps "gearslip-projection", the on-phone bench
|
||||||
|
* preview passes "gearslip-preview".
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
surface: Surface,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
densityDpi: Int,
|
||||||
|
name: String = "gearslip-projection",
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
@@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
// driver on every connect.
|
// driver on every connect.
|
||||||
val display = try {
|
val display = try {
|
||||||
displayManager.createVirtualDisplay(
|
displayManager.createVirtualDisplay(
|
||||||
"gearslip-projection", width, height, densityDpi, surface,
|
name, width, height, densityDpi, surface,
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -133,10 +133,16 @@ internal object LauncherCache {
|
|||||||
// own Messages screen does the same job, so a messenger gets only that tile.
|
// own Messages screen does the same job, so a messenger gets only that tile.
|
||||||
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
||||||
|
|
||||||
val nav = navApps.filter { it.component.packageName !in messagingPackages }.map {
|
val nav = navApps
|
||||||
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
.filter { it.component.packageName !in messagingPackages }
|
||||||
Entry(label, iconOf(it.component.packageName), template = it)
|
// An app whose template screen is rejected but whose player works through Gearslip
|
||||||
}
|
// (Spotify) shows only its player tile - a "· Browse" tile beside it would just report
|
||||||
|
// that the car screen isn't supported.
|
||||||
|
.filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) }
|
||||||
|
.map {
|
||||||
|
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
||||||
|
Entry(label, iconOf(it.component.packageName), template = it)
|
||||||
|
}
|
||||||
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
||||||
// A messenger that is also a car media app keeps only its player tile.
|
// A messenger that is also a car media app keeps only its player tile.
|
||||||
val messaging = messagingApps
|
val messaging = messagingApps
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
||||||
|
"${app.label} isn't running.",
|
||||||
|
"${app.label} plays through Gearslip's own player, but only while it's " +
|
||||||
|
"open on the phone. Open it once and it keeps playing here in the car.",
|
||||||
|
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
||||||
|
)
|
||||||
CarMedia.Rejection.REFUSED -> Notice(
|
CarMedia.Rejection.REFUSED -> Notice(
|
||||||
"${app.label} would not let Gearslip browse it.",
|
"${app.label} would not let Gearslip browse it.",
|
||||||
"Some media apps only accept Google's own host.",
|
"Some media apps only accept Google's own host.",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ object KnownApps {
|
|||||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||||
|
"com.mapquest.android.ace", // MapQuest: map, search, routing - works well
|
||||||
|
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||||
)
|
)
|
||||||
|
|
||||||
fun works(packageName: String): Boolean = packageName in working
|
fun works(packageName: String): Boolean = packageName in working
|
||||||
@@ -31,8 +33,10 @@ object KnownApps {
|
|||||||
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
|
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps whose car screen refuses Gearslip but whose player works. Only the "· Browse" tile
|
* Apps whose car screen refuses Gearslip but whose player works. When the player covers the
|
||||||
* gets the red X. Car library 1.9 and later accepts only Google's host on a phone.
|
* app (see [playerWorks]) the launcher drops the dead "· Browse" tile entirely rather than
|
||||||
|
* show it; otherwise that tile gets a red X. Car library 1.9 and later accepts only Google's
|
||||||
|
* host on a phone.
|
||||||
*/
|
*/
|
||||||
private val brokenScreen = setOf(
|
private val brokenScreen = setOf(
|
||||||
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
|
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.media
|
|||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.Rating
|
import android.media.Rating
|
||||||
@@ -19,6 +20,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.host.KnownApps
|
||||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -142,7 +144,7 @@ class CarMedia(private val context: Context) {
|
|||||||
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
||||||
|
|
||||||
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
||||||
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS }
|
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS, NEEDS_APP_RUNNING }
|
||||||
|
|
||||||
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
||||||
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
||||||
@@ -236,7 +238,7 @@ class CarMedia(private val context: Context) {
|
|||||||
* is out of reach. Reading another app's session needs Notification access, which the driver
|
* is out of reach. Reading another app's session needs Notification access, which the driver
|
||||||
* grants once on the phone.
|
* grants once on the phone.
|
||||||
*/
|
*/
|
||||||
private fun attachToSession(app: MediaApp, attempt: Int = 0) {
|
private fun attachToSession(app: MediaApp, attempt: Int = 0, launched: Boolean = false) {
|
||||||
if (connecting != app) return
|
if (connecting != app) return
|
||||||
val manager = context.getSystemService(MediaSessionManager::class.java)
|
val manager = context.getSystemService(MediaSessionManager::class.java)
|
||||||
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
||||||
@@ -249,12 +251,19 @@ class CarMedia(private val context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
|
val limit = if (launched) SESSION_RETRIES_LAUNCHED else SESSION_RETRIES
|
||||||
// The app only publishes its session once its service has started; give it a moment.
|
// The app only publishes its session once its service has started; give it a moment.
|
||||||
if (attempt < SESSION_RETRIES) {
|
if (attempt < limit) {
|
||||||
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
|
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
||||||
} else {
|
} else {
|
||||||
GearslipLog.w("media: ${app.label} has no active session to control")
|
GearslipLog.w("media: ${app.label} has no active session to control")
|
||||||
_rejection.value = Rejection.REFUSED
|
// A session-only app (Spotify) just isn't running - say so and offer to open it,
|
||||||
|
// rather than calling it unsupported.
|
||||||
|
_rejection.value = if (KnownApps.playerWorks(app.component.packageName)) {
|
||||||
|
Rejection.NEEDS_APP_RUNNING
|
||||||
|
} else {
|
||||||
|
Rejection.REFUSED
|
||||||
|
}
|
||||||
_phase.value = Phase.REJECTED
|
_phase.value = Phase.REJECTED
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -437,8 +446,38 @@ class CarMedia(private val context: Context) {
|
|||||||
_queue.value = emptyList()
|
_queue.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens [app] on the phone, then connects once it publishes its session. The driver asks for
|
||||||
|
* this from the "Open" button a session-only app (Spotify) shows when it isn't running yet.
|
||||||
|
*/
|
||||||
|
fun launchAndConnect(app: MediaApp) {
|
||||||
|
if (!openApp(app)) return
|
||||||
|
disconnect()
|
||||||
|
connecting = app
|
||||||
|
_phase.value = Phase.CONNECTING
|
||||||
|
// A cold start takes a few seconds to publish a session, so wait, then watch for longer.
|
||||||
|
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings [app] to the foreground on the phone so it publishes its session. */
|
||||||
|
private fun openApp(app: MediaApp): Boolean {
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
|
||||||
|
return runCatching {
|
||||||
|
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
GearslipLog.i("media: opened ${app.label} to bring up its session")
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
GearslipLog.w("media: could not open ${app.label}: ${it.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SESSION_RETRIES = 6
|
const val SESSION_RETRIES = 6
|
||||||
const val SESSION_RETRY_MS = 500L
|
const val SESSION_RETRY_MS = 500L
|
||||||
|
/** After opening a cold app, how long to wait before the first look for its session. */
|
||||||
|
const val LAUNCH_WAIT_MS = 1_500L
|
||||||
|
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||||
|
const val SESSION_RETRIES_LAUNCHED = 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}, handler)
|
}, handler)
|
||||||
|
|
||||||
projector.start(reader.surface, width, height, density) { CarUi() }
|
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
projector.stop()
|
projector.stop()
|
||||||
|
|||||||
Reference in new issue
Block a user