Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f1dff5661 |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -8,20 +8,32 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
# Full URLs: this Gitea resolves bare action names against itself,
|
||||||
|
# where these actions do not exist.
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- uses: https://github.com/actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: https://github.com/gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest --stacktrace
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug --stacktrace
|
run: ./gradlew assembleDebug --stacktrace
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: https://github.com/actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: castarr-debug-apk
|
name: castarr-debug-apk
|
||||||
path: app/build/outputs/apk/debug/app-debug.apk
|
path: app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
- name: Upload test report
|
||||||
|
if: always()
|
||||||
|
uses: https://github.com/actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: castarr-test-report
|
||||||
|
path: tests/runs/
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "dev.castarr.tv"
|
applicationId = "dev.castarr.tv"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 27
|
versionCode = 28
|
||||||
versionName = "0.10.1"
|
versionName = "0.10.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
// Release signing from environment (see ~/.keys/castarr-release.env on the
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AppState(
|
|||||||
val dispatcharr: DispatcharrRepository,
|
val dispatcharr: DispatcharrRepository,
|
||||||
) {
|
) {
|
||||||
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
|
||||||
|
val crests = dev.castarr.tv.data.Crests(context)
|
||||||
|
|
||||||
var screen by mutableStateOf(Screen.LIVE)
|
var screen by mutableStateOf(Screen.LIVE)
|
||||||
var playerVisible by mutableStateOf(false)
|
var playerVisible by mutableStateOf(false)
|
||||||
|
|||||||
54
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
54
app/src/main/java/dev/castarr/tv/data/Crests.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.castarr.tv.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves club crests at runtime from Wikipedia's page summary. The badges
|
||||||
|
* are trademarks, so they are fetched and cached on the device rather than
|
||||||
|
* shipped with the app — nothing copyrighted lives in the repository.
|
||||||
|
*/
|
||||||
|
class Crests(context: Context) {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("crests", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** Cached crest URL for a club, fetching it once per article. */
|
||||||
|
suspend fun urlFor(article: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
prefs.getString(article, null)?.let { return@withContext it.ifEmpty { null } }
|
||||||
|
val resolved = runCatching { fetch(article) }
|
||||||
|
.onFailure { Log.w(TAG, "crest lookup failed: ${it.javaClass.simpleName}") }
|
||||||
|
.getOrNull()
|
||||||
|
// Remember misses too, so a club without a crest is not looked up
|
||||||
|
// again on every screen.
|
||||||
|
prefs.edit().putString(article, resolved.orEmpty()).apply()
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(article: String): String? {
|
||||||
|
val encoded = URLEncoder.encode(article, "UTF-8").replace("+", "%20")
|
||||||
|
val connection = URL("$SUMMARY$encoded").openConnection() as HttpURLConnection
|
||||||
|
return try {
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 10_000
|
||||||
|
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||||
|
connection.setRequestProperty("Accept", "application/json")
|
||||||
|
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
JSONObject(body).optJSONObject("thumbnail")?.optString("source")
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Crests"
|
||||||
|
const val SUMMARY = "https://de.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
|
const val USER_AGENT = "Castarr TV (private use)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
/**
|
/**
|
||||||
* Per-club shortcut to "where does my club play tonight": a rail entry that
|
* Per-club shortcut to "where does my club play tonight": a rail entry that
|
||||||
* lists every channel whose EPG mentions the club within the next few hours.
|
* lists every channel whose EPG mentions the club within the next few hours.
|
||||||
* Club crests are trademarks, so the icon is a plain shield in the club
|
* Crests are fetched from Wikipedia at runtime and cached on the device;
|
||||||
* colours rather than the real badge.
|
* the club colours are the fallback while that is pending or unavailable.
|
||||||
*/
|
*/
|
||||||
data class TeamFilter(
|
data class TeamFilter(
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -18,6 +18,8 @@ data class TeamFilter(
|
|||||||
val needles: List<String>,
|
val needles: List<String>,
|
||||||
val primary: Color,
|
val primary: Color,
|
||||||
val secondary: Color,
|
val secondary: Color,
|
||||||
|
/** German Wikipedia article the crest is resolved from. */
|
||||||
|
val article: String,
|
||||||
) {
|
) {
|
||||||
fun matches(title: String): Boolean {
|
fun matches(title: String): Boolean {
|
||||||
val haystack = title.lowercase()
|
val haystack = title.lowercase()
|
||||||
@@ -43,7 +45,8 @@ object TeamFilters {
|
|||||||
needles: List<String>,
|
needles: List<String>,
|
||||||
primary: Long,
|
primary: Long,
|
||||||
secondary: Long,
|
secondary: Long,
|
||||||
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary))
|
article: String = fullName,
|
||||||
|
) = TeamFilter(key, label, fullName, needles, Color(primary), Color(secondary), article)
|
||||||
|
|
||||||
/** Clubs of the top three German divisions. */
|
/** Clubs of the top three German divisions. */
|
||||||
val all: List<TeamFilter> = listOf(
|
val all: List<TeamFilter> = listOf(
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ fun LiveScreen(state: AppState) {
|
|||||||
label = club.label,
|
label = club.label,
|
||||||
count = matchesByTeam[club.key]?.size ?: 0,
|
count = matchesByTeam[club.key]?.size ?: 0,
|
||||||
selected = state.activeTeam == club.key,
|
selected = state.activeTeam == club.key,
|
||||||
leading = { Crest(club) },
|
leading = { Crest(club, state) },
|
||||||
modifier = intoList.then(
|
modifier = intoList.then(
|
||||||
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
if (state.activeTeam == club.key) Modifier.focusRequester(railFocus)
|
||||||
else Modifier
|
else Modifier
|
||||||
@@ -433,11 +433,33 @@ private fun ChannelRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stand-in for the club badge: real crests are trademarks, so this draws a
|
* The club badge, resolved from Wikipedia and cached on the device. Until
|
||||||
* simple shield in the club colours instead.
|
* it arrives (or if it never does) a shield in the club colours stands in.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Crest(team: dev.castarr.tv.data.TeamFilter) {
|
private fun Crest(team: dev.castarr.tv.data.TeamFilter, state: AppState) {
|
||||||
|
val url by androidx.compose.runtime.produceState<String?>(null, team.key) {
|
||||||
|
value = state.crests.urlFor(team.article)
|
||||||
|
}
|
||||||
|
Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||||
|
val crestUrl = url
|
||||||
|
if (crestUrl != null) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = crestUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
loading = { ShieldFallback(team) },
|
||||||
|
error = { ShieldFallback(team) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ShieldFallback(team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShieldFallback(team: dev.castarr.tv.data.TeamFilter) {
|
||||||
Canvas(Modifier.size(18.dp)) {
|
Canvas(Modifier.size(18.dp)) {
|
||||||
val w = size.width
|
val w = size.width
|
||||||
val h = size.height
|
val h = size.height
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -308,7 +312,13 @@ fun SettingsScreen(state: AppState) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
||||||
val selectedFocus = remember { FocusRequester() }
|
val selectedFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { selectedFocus.requestFocus() }
|
// A requester bound to no composed row throws; with no preselection
|
||||||
|
// (adding something new) there is deliberately no such row.
|
||||||
|
LaunchedEffect(Unit) { runCatching { selectedFocus.requestFocus() } }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
LaunchedEffect(picker.selected) {
|
||||||
|
if (picker.selected > 0) listState.scrollToItem(picker.selected)
|
||||||
|
}
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = onClose,
|
onDismissRequest = onClose,
|
||||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
@@ -337,7 +347,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
letterSpacing = 2.sp,
|
letterSpacing = 2.sp,
|
||||||
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
modifier = Modifier.padding(start = 14.dp, bottom = 10.dp),
|
||||||
)
|
)
|
||||||
picker.options.forEachIndexed { index, option ->
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.heightIn(max = 420.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(picker.options) { index, option ->
|
||||||
val selected = index == picker.selected
|
val selected = index == picker.selected
|
||||||
Surface(
|
Surface(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -346,7 +360,11 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(if (selected) Modifier.focusRequester(selectedFocus) else Modifier),
|
.then(
|
||||||
|
if (selected || (picker.selected < 0 && index == 0))
|
||||||
|
Modifier.focusRequester(selectedFocus)
|
||||||
|
else Modifier
|
||||||
|
),
|
||||||
shape = ClickableSurfaceDefaults.shape(rowShape),
|
shape = ClickableSurfaceDefaults.shape(rowShape),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
@@ -387,6 +405,7 @@ private fun PickerDialog(picker: Picker, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user