mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 08:56:59 +01:00
Sync carried only watch positions, so a second device started empty. The per-installation history file now also carries per-key registers (pstr-core::profile): every share with its link and custom password, the playback, appearance and metadata settings, the TMDB key, hand-picked matches and per-title track choices. Newer updated_at wins per key, a removal travels as an empty register, and a first sync stamps at 0 so a fresh install takes the library the other devices have instead of overwriting it with defaults. Volume, mute and the device-only settings (cache budget, Wi-Fi only, background audio, hardware decoding) stay out. The secrets live only in the end-to-end encrypted Drive file, never in shares.json. FORMAT_VERSION stays 1; an older build ignores the registers. Adding a share stored it before opening the link, so a first failure left a broken entry and every retry said "already configured". add_verified opens the link first and stores nothing on failure, and only the new share is crawled afterwards, so another broken share cannot fail the add. Android drops picture-in-picture: the window sized the video wrongly and background audio already covers leaving the app. iOS sizes the Metal drawable on every layout, so the picture no longer stretches after a rotation or a Split View resize. Settings show a save at once rather than after the match run, and a failed save reverts and says why. Both phone clients gain an activity banner naming what is running, and the add-share, metadata and change-match forms show progress and errors in place.
147 lines
6.1 KiB
Kotlin
147 lines
6.1 KiB
Kotlin
package io.narl.protonstream
|
|
|
|
import android.Manifest
|
|
import android.content.ComponentName
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.ServiceConnection
|
|
import android.content.pm.PackageManager
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.os.IBinder
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.core.graphics.drawable.toDrawable
|
|
import androidx.core.view.WindowCompat
|
|
import androidx.lifecycle.lifecycleScope
|
|
import io.narl.protonstream.native.NativeRuntime
|
|
import io.narl.protonstream.playback.NativeMpvHost
|
|
import io.narl.protonstream.playback.PlaybackService
|
|
import io.narl.protonstream.settings.SettingsStore
|
|
import io.narl.protonstream.sync.WatchSyncWorker
|
|
import io.narl.protonstream.ui.ProtonStreamApp
|
|
import io.narl.protonstream.ui.theme.AppearanceState
|
|
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
import kotlinx.coroutines.flow.filterNotNull
|
|
import kotlinx.coroutines.launch
|
|
import uniffi.pstr_android.PaletteRecord
|
|
|
|
class MainActivity : ComponentActivity() {
|
|
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
|
|
|
/** A share link handed over by a browser or the share sheet, until the Add form takes it. */
|
|
private val incomingShareLink = mutableStateOf<String?>(null)
|
|
/**
|
|
* Whether a bind is outstanding, which is what has to be unbound.
|
|
*
|
|
* Set when `bindService` is called rather than when the connection arrives:
|
|
* an activity destroyed while the bind is still pending owes the system an
|
|
* `unbindService` just the same, and `onServiceConnected` may never run.
|
|
*/
|
|
private var bound = false
|
|
private val playbackConnection = object : ServiceConnection {
|
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
|
}
|
|
override fun onServiceDisconnected(name: ComponentName?) {
|
|
playerHost.value = null
|
|
}
|
|
}
|
|
|
|
private val notificationPermission = registerForActivityResult(
|
|
ActivityResultContracts.RequestPermission(),
|
|
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
// Before anything draws. The palette decides the window's own
|
|
// background and which way round the system bars' icons go, and both
|
|
// are wrong for a frame if they are set from a composition instead.
|
|
// Infallible by construction — an unreadable theme file resolves to the
|
|
// shipped default rather than throwing.
|
|
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
|
// Only on a fresh start: a recreated activity is handed the intent it
|
|
// was launched with again, and would offer to add the same share twice.
|
|
if (savedInstanceState == null) incomingShareLink.value = intent.shareLink()
|
|
enableEdgeToEdge()
|
|
// Re-applied on every change, not just at startup: picking Latte from
|
|
// the settings page has to turn the status bar's icons dark in the same
|
|
// frame the page behind them turns light.
|
|
lifecycleScope.launch {
|
|
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
|
|
}
|
|
bound = bindService(
|
|
Intent(this, PlaybackService::class.java),
|
|
playbackConnection,
|
|
Context.BIND_AUTO_CREATE,
|
|
)
|
|
if (
|
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
}
|
|
setContent {
|
|
ProtonStreamTheme {
|
|
ProtonStreamApp(
|
|
playerHost.value,
|
|
shareLink = incomingShareLink.value,
|
|
onShareLinkTaken = { incomingShareLink.value = null },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The parts of the window Compose does not reach: the background behind the
|
|
* whole activity, and the polarity of the system bars' icons.
|
|
*
|
|
* The background matters for the frames before and between compositions —
|
|
* the launch frame, and a rotation. It used to be a hex literal in
|
|
* `themes.xml`, which meant every cold start showed a colour from one
|
|
* flavour regardless of which one was chosen.
|
|
*
|
|
* The icons matter because the app ships a light flavour. Pinned dark, as
|
|
* they were, Latte drew white status-bar icons onto a near-white page.
|
|
*/
|
|
private fun dressWindow(palette: PaletteRecord) {
|
|
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
|
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
|
isAppearanceLightStatusBars = palette.light
|
|
isAppearanceLightNavigationBars = palette.light
|
|
}
|
|
}
|
|
|
|
// singleTask: a link opened while the app is running arrives here, not in
|
|
// a second activity.
|
|
override fun onNewIntent(intent: Intent) {
|
|
super.onNewIntent(intent)
|
|
intent.shareLink()?.let { incomingShareLink.value = it }
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
if (bound) {
|
|
unbindService(playbackConnection)
|
|
bound = false
|
|
}
|
|
super.onDestroy()
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
// Where the viewer stopped is what their other devices want next, and
|
|
// a process in the background may not live to the next timed sync.
|
|
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
|
if (
|
|
!isChangingConfigurations &&
|
|
!SettingsStore(this).backgroundAudio
|
|
) {
|
|
playerHost.value?.stop()
|
|
}
|
|
}
|
|
}
|