Compare commits

...

38 commits

Author SHA1 Message Date
670e95f2fa v1.1.0
All checks were successful
review/code-claude Claude cross-family review: approve, no blocking findings
-----BEGIN SSH SIGNATURE-----
 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K
 Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
 AAAAQOwlHRfQqaoMZESsY18BqmXMIMwtJzU8cAEB2FWHNv5x2j/ZsGi8ES7VNW+rYtgV/B
 KF9ctdqeDoocL8r8da+AY=
 -----END SSH SIGNATURE-----

Merge upstream v1.1.0 while preserving Wolfskin customizations
2026-11-05 01:39:09 +01:00
Damontecres
2bb662fd60
Release v1.1.0 2026-11-03 17:55:49 -05:00
Damontecres
1fdc7f7d84
Translations 2026-11-03 (#2169)
Update translations as of 2026-11-03

A big thank you to all of the contributors!
2026-11-03 16:45:35 -05:00
Damontecres
a3bd0137b3
Fix style 2026-11-03 16:35:02 -05:00
Damontecres
1d880c9562
Release v1.1.0 2026-11-03 15:28:48 -05:00
Damontecres
d711760dbe
Minor bug fix (#2167)
## Description
- Fixes typo that could prevent restoring a page with artists or album
artists
- Clarified some javadocs

### Related issues
N/A

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
2026-11-03 14:52:56 -05:00
coulterj
06c1d622bb
Fix HDHomeRun Live TV direct playback by including the live stream ID (#2163)
## Description

I was having trouble playing HDHomeRun channels through Jellyfin in
Wholphin. They started quickly in the official Jellyfin app, but
Wholphin would sometimes spin or fall back when switching channels.

This change includes the live stream ID returned by Jellyfin when
building the Direct Play URL. It also adds a regression test.

### Related issues

Related to #2014 and possibly #1903. My testing was with an HDHomeRun; I
haven’t verified the TVHeadend setups in those reports.

### Testing

Tested on a Google TV Streamer with multiple local channels, FAST
channels, and movie playback. The tested content used Direct Play, and
the HDHomeRun tuners were released after stopping.

All 18 playback tests and the formatting check passed after the
requested test cleanup.

## Screenshots

No UI changes.

## AI or LLM usage

I used OpenAI Codex to help investigate the issue, implement the change
in PlaybackViewModel.kt, add the regression test in
PlaybackViewModelTests.kt, and run validation. Codex also helped draft
this description.
2026-11-03 11:18:41 -05:00
TheMatrixan
e53e8b7c67
Translated using Weblate (Polish)
Currently translated at 100.0% (696 of 696 strings)

Translation: Wholphin/Wholphin
Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/
2026-11-03 07:27:18 +00:00
ymir
c5a07f1ab8
Translated using Weblate (French)
Currently translated at 100.0% (696 of 696 strings)

Translation: Wholphin/Wholphin
Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/
2026-11-03 07:27:16 +00:00
Priit Jõerüüt
b04cba4d6e
Translated using Weblate (Estonian)
Currently translated at 100.0% (696 of 696 strings)

Translation: Wholphin/Wholphin
Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/
2026-11-03 07:27:16 +00:00
6bb382a126 Merge pull request 'Show PWA source ratings on every surface through one shared rating row' (#26) from fm/wolfskin-25-ratings-everywhere into main 2026-10-29 21:51:24 +01:00
85365aa3c6 Show each episode's own TMDB score next to the series ratings
All checks were successful
review/code-claude Claude code review: pass, no blocking findings (episode TMDB badge)
2026-10-29 21:35:13 +01:00
2ccce98dc1 Show PWA source ratings on every surface through one shared rating row
All checks were successful
review/code-claude Claude code review: pass, no blocking findings
2026-10-29 21:04:39 +01:00
853a3989b2 Merge pull request 'Show PWA source ratings (RT, IMDb, TMDB from Seerr) on the TV' (#24) from fm/wolfskin-23-ratings into main 2026-10-29 20:29:43 +01:00
04d659b115 Tolerate unknown RT labels, expire cached ratings, avoid duplicate collectors and Jellyfin flash
All checks were successful
review/code-claude Reviewed c54acc9c (PASS); review-fix delta re-checked by firstmate
2026-10-29 20:28:51 +01:00
c54acc9c1d Show Rotten Tomatoes before IMDb like the PWA
All checks were successful
review/code-claude Claude code review: pass, no blocking findings
2026-10-29 20:15:08 +01:00
38db9e5127 Show Seerr source ratings on Jellyfin movie and series details 2026-10-29 20:00:31 +01:00
15b082ccd8 Show PWA source ratings on Discover screens 2026-10-29 20:00:31 +01:00
e92ade99df Add source ratings model and Seerr ratings service matching the PWA 2026-10-29 20:00:31 +01:00
d78a23a80a Merge pull request 'Serialize Gradle containers sharing a cache volume' (#22) from fm/wolfskin-14-gradle-lock into main 2026-10-27 17:03:03 +01:00
221f623c87 Serialize Gradle containers sharing a cache volume
All checks were successful
review/code-claude Approve: lock held across container, no deadlock, releases on all exits
2026-10-27 15:30:44 +01:00
761a5a6145 Merge pull request 'Use Forgejo releases for Wolfskin updates and match installed versions consistently' (#20) from fm/wolfskin-5-updater-gpt into main 2026-10-22 16:13:20 +02:00
1715041df3 Match updater versions by ordering and cover Wolfskin asset selection
All checks were successful
review/code-claude Approved: no blocking findings
2026-10-22 16:06:09 +02:00
be7268cc96 Merge pull request 'Default Seerr login to Jellyfin and prefill the Wolfskin Seerr URL' (#19) from fm/wolfskin-6-seerr-default into main 2026-10-22 16:00:23 +02:00
4ae2171061 Merge pull request 'Change application id to org.justinvisser.wolfskin.tv' (#18) from fm/wolfskin-3-package-id into main 2026-10-22 15:58:32 +02:00
bc220021a8 Merge pull request 'Rebrand the Android TV app as Wolfskin' (#16) from fm/wolfskin-2-rebrand into main 2026-10-22 15:23:03 +02:00
df8614bf8b Merge pull request 'Focus the active profile in the Select User list' (#15) from fm/wolfskin-7-profile-focus into main 2026-10-22 15:11:45 +02:00
c98a9756ac Integrate Wolfskin into theme-specific controls
All checks were successful
review/design-claude Approved at c98a9756; no blockers, see PR comment
review/code-claude Approved at c98a9756; no blockers, see PR comment
2026-10-22 15:07:58 +02:00
e499c3f118 Rebrand the TV app as Wolfskin 2026-10-22 15:05:37 +02:00
0f7a23faa8 Point updater at Forgejo releases and Wolfskin assets
Some checks failed
review/code-gpt Request changes: version equivalence and lost positive asset-selection coverage
2026-10-22 14:47:50 +02:00
af885eae58 Default Seerr login to Jellyfin and prefill Wolfskin Seerr URL
All checks were successful
review/firstmate Exact-diff check: JELLYFIN first, API key last; URL prefill from one constant; unit test; apk-debug+test green
2026-10-22 14:46:41 +02:00
06c783a44a Change application id to org.justinvisser.wolfskin.tv
All checks were successful
review/firstmate Exact-diff check: only applicationId line + Intents.md adb examples; aapt2 badging proof org.justinvisser.wolfskin.tv.debug; apk-debug+test green
2026-10-22 14:46:18 +02:00
344aa9990d Focus the active profile in the user selector
All checks were successful
review/firstmate Diff byte-identical to Justin's upstream commit f816ef03 (Wholphin#2003); worker ran make test + apk-debug green
2026-10-22 14:46:05 +02:00
7d6f041b55 Merge pull request 'Add Wolfskin fork header to README' (#12) from fm/wolfskin-1-readme into main 2026-10-22 14:44:39 +02:00
f312138a75 Merge pull request 'Add pinned Docker APK builds and release signing' (#13) from fm/wolfskin-4-build into main 2026-10-22 14:44:21 +02:00
19adf54c23 Pin platform tools and use the upstream debug unit task
All checks were successful
review/security-claude Carried from reviewed head: identical tree (re-pushed only to shed stale Actions statuses)
review/code-claude Carried from reviewed head: identical tree (re-pushed only to shed stale Actions statuses)
2026-10-22 14:43:07 +02:00
1db83e98d4 Add Wolfskin fork header to README
All checks were successful
review/firstmate Carried from reviewed head: identical tree (re-pushed only to shed stale Actions statuses)
2026-10-22 14:42:47 +02:00
3d6f8bae1a Add pinned container builds and Wolfskin release signing 2026-10-22 12:45:29 +02:00
121 changed files with 3354 additions and 733 deletions

View file

@ -33,7 +33,7 @@ Full format
```bash
adb shell am start \
-a android.intent.action.SEARCH \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es query "Avengers"
```
@ -47,7 +47,7 @@ Multi-word query:
# Whole shell command is wrapped in double quotes
adb shell "am start \
-a android.intent.action.SEARCH \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es query 'Avengers Endgame'"
adb shell am start -d 'wholphin://search?query=Avengers+Endgame'
@ -72,7 +72,7 @@ Full format
```bash
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```
@ -98,7 +98,7 @@ Full format
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```
@ -111,7 +111,7 @@ Start at 4m30s:
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--el position 270000
@ -122,7 +122,7 @@ Shuffle a series (`itemId` is the Series ID):
```bash
adb shell am start \
-a com.github.damontecres.wholphin.PLAYBACK \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--ez shuffle true
@ -138,7 +138,7 @@ Full format
# Switch user, go to item
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
@ -146,7 +146,7 @@ adb shell am start \
# Switch user, go to home screen
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin/.MainActivity' \
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
--es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \
--es userId "5c8575e0-468d-44fb-8c70-00aa79160587"
```
@ -163,6 +163,6 @@ Debug builds have a different package, so you must specify the component by its
```bash
adb shell am start \
-a android.intent.action.VIEW \
-n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \
-n 'org.justinvisser.wolfskin.tv.debug/com.github.damontecres.wholphin.MainActivity' \
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
```

21
Makefile Normal file
View file

@ -0,0 +1,21 @@
.DEFAULT_GOAL := apk
WOLFSKIN_BUILD_IMAGE ?= wolfskin-build:sdk37-jdk21
WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
.PHONY: build-image apk apk-debug test release-tag
build-image:
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
apk: build-image
@bash docker/gradle.sh release clean assembleDefaultRelease
apk-debug: build-image
@bash docker/gradle.sh debug assembleDefaultDebug
test: build-image
@bash docker/gradle.sh debug testDefaultDebugUnitTest
release-tag:
@bash docker/release-tag.sh

View file

@ -1,3 +1,15 @@
# Wolfskin
Wolfskin is a personal fork of [Wholphin](https://github.com/damontecres/Wholphin) by damontecres.
Licensed under GPL-2.0, the same licence as upstream (see [LICENSE](LICENSE)).
Wolfskin is an Android TV client for one household. It is released through Forgejo releases on this repo, and it tracks upstream by merging each upstream release tag.
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
---
# Wholphin - an OSS Android TV client for Jellyfin
> "Never half-phin two jellies. Always wholphin one jelly."

View file

@ -72,7 +72,7 @@ configure<ApplicationExtension> {
compileSdk = libs.versions.compileSdk.getInt()
defaultConfig {
applicationId = "com.github.damontecres.wholphin"
applicationId = "org.justinvisser.wolfskin.tv"
minSdk = libs.versions.minSdk.getInt()
targetSdk = libs.versions.targetSdk.getInt()
versionCode = gitTags.trim().lines().size

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Wholphin (Debug)</string>
<string name="app_name" translatable="false">Wolfskin (Debug)</string>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Before After
Before After

View file

@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
@ -59,12 +60,13 @@ import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -118,6 +120,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var imageUrlService: ImageUrlService
@Inject
lateinit var seerrRatingsService: SeerrRatingsService
@Inject
lateinit var refreshRateService: RefreshRateService
@ -229,7 +234,7 @@ class MainActivity : AppCompatActivity() {
viewModel.appStart(intent)
setContent {
MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) {
MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) {
Surface(Modifier.fillMaxSize()) {
val userPreferences by userPreferencesService.flow.collectAsState(null)
if (userPreferences == null) {
@ -262,7 +267,10 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
}
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
CompositionLocalProvider(
LocalImageUrlService provides imageUrlService,
LocalSeerrRatingsService provides seerrRatingsService,
) {
WholphinTheme(
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,

View file

@ -0,0 +1,9 @@
package com.github.damontecres.wholphin
/** Wolfskin-specific defaults kept in one place to keep upstream diffs small. */
object WolfskinDefaults {
const val SEERR_URL = "https://seerr.justinvisser.org"
}
/** Returns [url] when it has content, otherwise the Wolfskin Seerr URL so the user can still edit it. */
fun seerrUrlPrefill(url: String = ""): String = if (url.isBlank()) WolfskinDefaults.SEERR_URL else url

View file

@ -16,7 +16,7 @@ import java.util.UUID
/**
* Represents the status of the most recently used server
*
* This can be read/written to shared preferences
* This can be saved/retrieved from a [MostRecentServerProvider]
*/
sealed interface MostRecentServer {
/**
@ -58,12 +58,12 @@ sealed interface MostRecentServer {
interface MostRecentServerProvider {
/**
* Get the [MostRecentServer] from the shared preferences in [context]
* Get the [MostRecentServer] from this provider
*/
fun get(): MostRecentServer
/**
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
* Save the [CurrentUser] as the most recent server
*/
suspend fun save(current: CurrentUser)
@ -86,9 +86,6 @@ class MostRecentServerSharedPreferences(
) : MostRecentServerProvider {
private val prefs = getServerSharedPreferences(context)
/**
* Get the [MostRecentServer] from the shared preferences in [context]
*/
override fun get(): MostRecentServer {
val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull()
val serverUrl = prefs.getString(SERVER_URL_KEY, null)
@ -103,9 +100,6 @@ class MostRecentServerSharedPreferences(
}
}
/**
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
*/
override suspend fun save(current: CurrentUser) {
prefs.edit(true) {
putString(SERVER_ID_KEY, current.server.id.toServerString())

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
@ -32,7 +31,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
import kotlin.time.Duration
@ -235,26 +233,6 @@ data class BaseItem(
append(it)
}
},
communityRating =
data.communityRating?.let {
buildAnnotatedString {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
},
criticRating =
data.criticRating?.let {
buildAnnotatedString {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
}
},
),
)
@ -370,8 +348,6 @@ data class BaseItemUi(
data class QuickDetailsData(
val basic: AnnotatedString,
val officialRating: AnnotatedString? = null,
val criticRating: AnnotatedString? = null,
val communityRating: AnnotatedString? = null,
)
/**

View file

@ -3,8 +3,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.LocalDateSerializer
@ -123,20 +121,3 @@ data class DiscoverItem(
}
}
}
/**
* A rating for a discovered item which is usually fetched separately from the item
*/
data class DiscoverRating(
val criticRating: Int?,
val audienceRating: Float?,
) {
constructor(rating: MovieMovieIdRatingsGet200Response) : this(
criticRating = rating.criticsScore,
audienceRating = rating.audienceScore?.div(10f),
)
constructor(rating: TvTvIdRatingsGet200Response) : this(
criticRating = rating.criticsScore,
audienceRating = null,
)
}

View file

@ -0,0 +1,231 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Immutable
import java.util.Locale
/**
* Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage.
*/
enum class RtMark {
FRESH,
ROTTEN,
UPRIGHT,
SPILLED,
/** Seerr sent a score without a recognised label */
UNKNOWN,
;
companion object {
/**
* Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null
*/
fun fromLabel(label: String?): RtMark? =
when (label) {
"Certified Fresh", "Fresh" -> FRESH
"Rotten" -> ROTTEN
"Upright" -> UPRIGHT
"Spilled" -> SPILLED
else -> null
}
}
}
@Immutable
data class RtScore(
/** 0-100 */
val percent: Int,
val mark: RtMark,
)
/**
* The badge drawn in front of a rating's number. One per PWA icon.
*/
enum class RatingBadge {
RT_FRESH,
RT_ROTTEN,
RT_UPRIGHT,
RT_SPILLED,
/** Rotten Tomatoes without a recognised label, drawn muted */
RT_UNKNOWN,
IMDB,
TMDB,
}
/**
* One displayable rating: a badge and its number
*/
@Immutable
sealed interface RatingEntry {
val text: String
val badge: RatingBadge
/**
* An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series
*/
val isEpisode: Boolean get() = false
data class RottenTomatoes(
val score: RtScore,
/** Critics (Tomatometer) or audience (Popcornmeter) */
val critics: Boolean,
) : RatingEntry {
override val text: String get() = "${score.percent}%"
override val badge: RatingBadge
get() =
when (score.mark) {
RtMark.FRESH -> RatingBadge.RT_FRESH
RtMark.ROTTEN -> RatingBadge.RT_ROTTEN
RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT
RtMark.SPILLED -> RatingBadge.RT_SPILLED
RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN
}
}
data class Imdb(
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val badge: RatingBadge get() = RatingBadge.IMDB
}
data class Tmdb(
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val badge: RatingBadge get() = RatingBadge.TMDB
}
data class EpisodeTmdb(
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val badge: RatingBadge get() = RatingBadge.TMDB
override val isEpisode: Boolean get() = true
}
}
private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
/**
* Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience,
* IMDb (movies only), and TMDB.
*
* Only scores that were actually observed are present. Use [of] to apply the PWA's rules.
*/
@Immutable
data class SourceRatings(
val rtCritics: RtScore? = null,
val rtAudience: RtScore? = null,
val imdb: Double? = null,
val tmdb: Double? = null,
/** The TMDB user score of the episode itself, for episodes */
val episodeTmdb: Double? = null,
) {
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null
/**
* Rotten Tomatoes entries in PWA order: critics then audience
*/
val criticEntries: List<RatingEntry.RottenTomatoes>
get() =
listOfNotNull(
rtCritics?.let { RatingEntry.RottenTomatoes(it, true) },
rtAudience?.let { RatingEntry.RottenTomatoes(it, false) },
)
/**
* IMDb or TMDB
*/
val communityEntries: List<RatingEntry>
get() =
listOfNotNull(
imdb?.let { RatingEntry.Imdb(it) },
tmdb?.let { RatingEntry.Tmdb(it) },
episodeTmdb?.let { RatingEntry.EpisodeTmdb(it) },
)
/**
* These ratings of a series with the TMDB score of one of its episodes. Rotten Tomatoes stays for context, but the
* series' own TMDB score is dropped so there is a single TMDB number, which is the episode's.
*
* @param episodeScore from [episodeScore], if null these ratings are unchanged
*/
fun withEpisode(episodeScore: Double?): SourceRatings = if (episodeScore == null) this else copy(tmdb = null, episodeTmdb = episodeScore)
/**
* The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB).
*
* Rotten Tomatoes is controlled by the critic rating display setting and IMDb/TMDB by the community rating setting.
*/
fun visibleEntries(
community: Boolean,
critic: Boolean,
): List<RatingEntry> = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList())
companion object {
const val MAX_PERCENT = 100.0
const val MAX_SCORE = 10.0
/**
* An episode's TMDB user score by the PWA's rules for a user score: only if observed, within 0-10, and with at least one vote
* (no votes or no vote count is unrated, never 0)
*/
fun episodeScore(
score: Number?,
votes: Long?,
): Double? = if (votes == null) null else userScore(score, votes)
/**
* Builds ratings using the PWA's rules:
* - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped
* - A zero-vote IMDb/TMDB score is unrated, never 0
* - RT icon comes from the label Seerr reports
* - TMDB is kept only when no other source has a score
*
* @param rtCriticsLabel Seerr's critics status: Certified Fresh, Fresh, or Rotten
* @param rtAudienceLabel Seerr's audience status: Upright or Spilled
*/
fun of(
rtCriticsScore: Number? = null,
rtCriticsLabel: String? = null,
rtAudienceScore: Number? = null,
rtAudienceLabel: String? = null,
imdbScore: Number? = null,
imdbVotes: Long? = null,
tmdbScore: Number? = null,
tmdbVotes: Long? = null,
): SourceRatings {
val critics = rtScore(rtCriticsScore, rtCriticsLabel, RtMark.UNKNOWN)
val audience = rtScore(rtAudienceScore, rtAudienceLabel, RtMark.UPRIGHT)
val imdb = userScore(imdbScore, imdbVotes)
val tmdb = userScore(tmdbScore, tmdbVotes)
return if (critics != null || audience != null || imdb != null) {
SourceRatings(critics, audience, imdb, null)
} else {
SourceRatings(tmdb = tmdb)
}
}
private fun rtScore(
score: Number?,
label: String?,
defaultMark: RtMark,
): RtScore? {
val value = score?.toDouble() ?: return null
if (value.isNaN() || value < 0.0 || value > MAX_PERCENT) return null
return RtScore(value.toInt(), RtMark.fromLabel(label) ?: defaultMark)
}
private fun userScore(
score: Number?,
votes: Long?,
): Double? {
val value = score?.toDouble() ?: return null
if (value.isNaN() || value < 0.0 || value > MAX_SCORE) return null
if (votes != null && votes <= 0L) return null
return value
}
}
}

View file

@ -557,7 +557,7 @@ sealed interface AppPreference<Pref, T> {
val ThemeColors =
AppChoicePreference<AppPreferences, AppThemeColors>(
title = R.string.app_theme,
defaultValue = AppThemeColors.PURPLE,
defaultValue = AppThemeColors.WOLFSKIN,
getter = { it.interfacePreferences.appThemeColors },
setter = { prefs, value ->
prefs.updateInterfacePreferences { appThemeColors = value }
@ -627,7 +627,7 @@ sealed interface AppPreference<Pref, T> {
val UpdateUrl =
AppStringPreference<AppPreferences>(
title = R.string.update_url,
defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest",
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
getter = { it.updateUrl },
setter = { prefs, value ->
prefs.update { updateUrl = value }

View file

@ -6,7 +6,7 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.HomeSlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.async
@ -58,7 +58,7 @@ class LatestNextUpService
val request =
GetResumeItemsRequest(
userId = userId,
fields = SlimItemFields,
fields = HomeSlimItemFields,
limit = limit,
mediaTypes = listOf(MediaType.VIDEO),
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
@ -90,7 +90,7 @@ class LatestNextUpService
val request =
GetNextUpRequest(
userId = userId,
fields = SlimItemFields,
fields = HomeSlimItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,

View file

@ -0,0 +1,411 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.Optional
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* The TMDB user vote average & count as reported by Seerr's movie/TV details
*/
data class TmdbVote(
val average: Double?,
val count: Long?,
)
/**
* The Seerr title whose ratings are shown for a Jellyfin item
*/
data class RatingsTarget(
val type: SeerrItemType,
val tmdbId: Int,
)
/**
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings
* reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings].
*
* Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else:
* - movies show their own ratings
* - series show their own ratings
* - seasons show their series' ratings, as the PWA does for the title page a season belongs to
* - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season
* Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it.
* - everything else (collections, music, photos, programs, ...) has none
*
* Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from
* `voteAverage`/`voteCount` and is only requested when no other source has a score.
*
* Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class SeerrRatingsService
@Inject
constructor(
private val seerrService: SeerrService,
private val api: ApiClient,
) {
private data class Key(
val type: SeerrItemType,
val tmdbId: Int,
)
private val cache =
CacheBuilder
.newBuilder()
.maximumSize(CACHE_SIZE)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>()
private data class SeasonKey(
val tmdbId: Int,
val season: Int,
)
/**
* The TMDB vote of each episode (by episode number) of a series' season
*/
private val seasonCache =
CacheBuilder
.newBuilder()
.maximumSize(CACHE_SIZE)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<SeasonKey, Map<Int, TmdbVote>>()
/**
* The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page
* don't carry their series' provider ids, so this is how they are found.
*/
private val tmdbIds =
CacheBuilder
.newBuilder()
.maximumSize(CACHE_SIZE)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<UUID, Optional<Int>>()
fun cached(
type: SeerrItemType,
tmdbId: Int,
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
/**
* The Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings.
* An episode's or season's own provider ids are for the episode or season, so those use the series.
*/
private fun ownerId(item: BaseItem): UUID? =
when (item.type) {
BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id
BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId
else -> null
}
/**
* The TMDB id if it is known without a request: the item's own provider ids or an earlier lookup.
* Present but empty means the item definitely has no TMDB id, null means unknown.
*/
private fun knownTmdbId(
item: BaseItem,
ownerId: UUID,
): Optional<Int>? =
item.data.providerIds
?.takeIf { ownerId == item.id && it.isNotEmpty() }
?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) }
?: tmdbIds.getIfPresent(ownerId)
private fun type(item: BaseItem): SeerrItemType? =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV
else -> null
}
private fun knownTarget(item: BaseItem): RatingsTarget? {
val type = type(item) ?: return null
val owner = ownerId(item) ?: return null
return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) }
}
/**
* Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand.
* Null if the item has no ratings, no TMDB id or the lookup failed.
*/
suspend fun resolveTarget(item: BaseItem): RatingsTarget? {
val type = type(item) ?: return null
val owner = ownerId(item) ?: return null
val tmdb =
knownTmdbId(item, owner) ?: try {
Optional
.ofNullable(
api.userLibraryApi
.getItem(owner)
.content.providerIds
?.get("Tmdb")
?.toIntOrNull(),
).also { tmdbIds.put(owner, it) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.w(ex, "Error fetching provider ids for %s", owner)
return null
}
return tmdb.orElse(null)?.let { RatingsTarget(type, it) }
}
/**
* The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty
*/
fun cachedFor(item: BaseItem): SourceRatings? {
val target = knownTarget(item) ?: return null
val series = cached(target.type, target.tmdbId) ?: return null
if (item.type != BaseItemKind.EPISODE) return series
val season = item.data.parentIndexNumber
val episode = item.data.indexNumber
if (season == null || episode == null) return series
val votes = seasonCache.getIfPresent(SeasonKey(target.tmdbId, season)) ?: return null
return series.withEpisode(episodeScore(votes[episode]))
}
private fun episodeScore(vote: TmdbVote?) = vote?.let { SourceRatings.episodeScore(it.average, it.count) }
/**
* Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established
* asynchronously after login.
*
* Already cached ratings are emitted at once. Otherwise nothing is requested until [debounceMillis] has passed, so
* collecting for each focused item while scrolling quickly only asks Seerr about the item the user stops on:
* collecting is cancelled for the previous item, which cancels the wait or the request in progress.
*
* Emits null while Seerr is not active, the item has no ratings (see above) or no TMDB id, or the lookup failed.
*/
fun forItem(
item: BaseItem?,
debounceMillis: Long = DEBOUNCE_MILLIS,
): Flow<SourceRatings?> {
if (item == null || type(item) == null) return flowOf(null)
cachedFor(item)?.let { return flowOf(it) }
val owner = ownerId(item) ?: return flowOf(null)
if (knownTmdbId(item, owner)?.isPresent == false) return flowOf(null)
return flow {
delay(debounceMillis)
emitAll(
seerrService.active
.distinctUntilChanged()
.mapLatest { active ->
if (active) resolveTarget(item)?.let { getFor(item, it) } else null
},
)
}
}
/**
* The ratings for the target of an item: for an episode, those of its series with its own TMDB score
*/
private suspend fun getFor(
item: BaseItem,
target: RatingsTarget,
): SourceRatings? {
val series = get(target.type, target.tmdbId)
val season = item.data.parentIndexNumber
val episode = item.data.indexNumber
if (item.type != BaseItemKind.EPISODE || season == null || episode == null) return series
val votes = getSeason(target.tmdbId, season)
if (series == null && votes == null) return null
return (series ?: SourceRatings()).withEpisode(episodeScore(votes?.get(episode)))
}
/**
* The TMDB votes of the episodes of a season, fetched once for all of them. Null if the request failed, which is not cached.
*/
private suspend fun getSeason(
tmdbId: Int,
season: Int,
): Map<Int, TmdbVote>? {
val key = SeasonKey(tmdbId, season)
seasonCache.getIfPresent(key)?.let { return it }
val votes =
try {
seerrService.api.tvApi
.tvTvIdSeasonSeasonNumberGet(tvId = tmdbId, seasonNumber = season)
.episodes
.orEmpty()
.mapNotNull { episode ->
episode.episodeNumber?.let { it to TmdbVote(episode.voteAverage?.toDouble(), episode.voteCount) }
}.toMap()
} catch (ex: CancellationException) {
throw ex
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
// TMDB has no such season, that is an answer rather than a failure
emptyMap()
} else {
Timber.w(ex, "Error fetching season %s of %s", season, tmdbId)
return null
}
} catch (ex: Exception) {
Timber.w(ex, "Error fetching season %s of %s", season, tmdbId)
return null
}
seasonCache.put(key, votes)
return votes
}
/**
* @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request
* @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now
*/
suspend fun get(
type: SeerrItemType,
tmdbId: Int,
knownTmdb: TmdbVote? = null,
): SourceRatings? {
val key = Key(type, tmdbId)
cache.getIfPresent(key)?.let { return it }
if (type != SeerrItemType.MOVIE && type != SeerrItemType.TV) return null
val combined =
try {
fetchCombined(type, tmdbId)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.w(ex, "Error fetching ratings for %s %s", type, tmdbId)
return null
}
var ratings = build(combined, null)
if (ratings.isEmpty) {
// Nothing else has a score, so TMDB is the only possible source
val vote =
knownTmdb ?: try {
fetchTmdb(type, tmdbId)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.w(ex, "Error fetching TMDB rating for %s %s", type, tmdbId)
return null
}
ratings = build(combined, vote)
}
cache.put(key, ratings)
return ratings
}
private suspend fun fetchCombined(
type: SeerrItemType,
tmdbId: Int,
): Combined =
try {
when (type) {
SeerrItemType.MOVIE -> {
Combined.Movie(seerrService.api.moviesApi.movieMovieIdRatingscombinedGet(movieId = tmdbId))
}
else -> {
Combined.Tv(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = tmdbId))
}
}
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
// Seerr has no RT/IMDb data for this title, that is an answer rather than a failure
Timber.d("No ratings for %s %s", type, tmdbId)
Combined.None
} else {
throw ex
}
}
private suspend fun fetchTmdb(
type: SeerrItemType,
tmdbId: Int,
): TmdbVote =
when (type) {
SeerrItemType.MOVIE -> {
seerrService.api.moviesApi
.movieMovieIdGet(movieId = tmdbId)
.let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) }
}
else -> {
seerrService.api.tvApi
.tvTvIdGet(tvId = tmdbId)
.let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) }
}
}
private fun build(
combined: Combined,
tmdb: TmdbVote?,
): SourceRatings =
when (combined) {
is Combined.Movie -> buildMovie(combined.response, tmdb)
is Combined.Tv -> {
val rt = combined.response
SourceRatings.of(
rtCriticsScore = rt.criticsScore,
rtCriticsLabel = rt.criticsRating,
rtAudienceScore = rt.audienceScore,
rtAudienceLabel = rt.audienceRating,
tmdbScore = tmdb?.average,
tmdbVotes = tmdb?.count,
)
}
Combined.None -> {
SourceRatings.of(tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count)
}
}
private fun buildMovie(
response: MovieMovieIdRatingscombinedGet200Response,
tmdb: TmdbVote?,
): SourceRatings =
SourceRatings.of(
rtCriticsScore = response.rt?.criticsScore,
rtCriticsLabel = response.rt?.criticsRating,
rtAudienceScore = response.rt?.audienceScore,
rtAudienceLabel = response.rt?.audienceRating,
imdbScore = response.imdb?.criticsScore,
imdbVotes = response.imdb?.criticsScoreCount,
tmdbScore = tmdb?.average,
tmdbVotes = tmdb?.count,
)
private sealed interface Combined {
data class Movie(
val response: MovieMovieIdRatingscombinedGet200Response,
) : Combined
data class Tv(
val response: TvTvIdRatingsGet200Response,
) : Combined
data object None : Combined
}
companion object {
private const val CACHE_SIZE = 300L
private const val CACHE_HOURS = 6L
const val DEBOUNCE_MILLIS = 350L
}
}

View file

@ -57,7 +57,7 @@ class UpdateChecker
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
companion object {
const val ASSET_NAME = "Wholphin"
const val ASSET_NAME = "Wolfskin"
const val APK_NAME = "$ASSET_NAME.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
@ -65,6 +65,53 @@ class UpdateChecker
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = BuildConfig.UPDATING_ENABLED
private const val RELEASES_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases?limit=50"
/**
* Find the release in a Forgejo release list whose name parses to the given version
*/
fun findReleaseByVersion(
releases: JsonArray,
version: Version,
): JsonObject? =
releases
.firstOrNull { release ->
val candidate = Version.tryFromString(release.jsonObject["name"]?.jsonPrimitive?.contentOrNull)
candidate != null && !candidate.isGreaterThan(version) && !version.isGreaterThan(candidate)
}?.jsonObject
/**
* Parse a single release object (Forgejo or GitHub shape) into a [Release], or null if its name is not a version
*/
fun parseRelease(
json: JsonObject,
debug: Boolean,
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
): Release? {
val name = json["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
if (version == null) {
Timber.w("Update version parsing failed. name=$name")
return null
}
val publishedAt = json["published_at"]?.jsonPrimitive?.contentOrNull
val body = json["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
json["assets"]?.jsonArray?.let { assets -> getDownloadUrl(assets, debug, supportedABIs) }
Timber.v("version=$version, downloadUrl=$downloadUrl")
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
emptyList()
}
return Release(version, downloadUrl, publishedAt, body, notes)
}
}
/**
@ -127,17 +174,29 @@ class UpdateChecker
return Version.fromString(pkgInfo.versionName!!)
}
/**
* Get the release whose name parses to the given version.
*
* Wolfskin releases are named by `git describe` (e.g. `v1.0.9-2-g3094fc8c`) rather than tagged `vX.Y.Z`,
* so this searches the release list instead of fetching a tag.
*/
suspend fun getRelease(version: Version): Release? {
val url =
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
val request =
Request
.Builder()
.url(RELEASES_URL)
.get()
.build()
return withContext(WholphinDispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
getRelease(request)
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) {
val releases = Json.parseToJsonElement(it.body.string()).jsonArray
findReleaseByVersion(releases, version)?.let { json -> parseRelease(json, BuildConfig.DEBUG) }
} else {
Timber.w("Release lookup failed ${it.code}: ${it.message}")
null
}
}
}
}
@ -158,36 +217,11 @@ class UpdateChecker
private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful) {
val result = Json.parseToJsonElement(it.body.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
Timber.v("version=$version, downloadUrl=$downloadUrl")
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
emptyList()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.w("Update version parsing failed. name=$name")
}
parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG)
} else {
Timber.w("Update check failed ${it.code}: ${it.message}")
null
}
return@use null
}
}

View file

@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
@ -242,30 +241,12 @@ val MediaSegmentType.skipStringRes: Int
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
fun listToDotString(
strings: List<String>,
communityRating: Float?,
criticRating: Float?,
): AnnotatedString =
fun listToDotString(strings: List<String>): AnnotatedString =
buildAnnotatedString {
strings.forEachIndexed { index, string ->
append(string)
if (index != strings.lastIndex) dot()
}
communityRating?.let {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
criticRating?.let {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
}
}
@get:StringRes

View file

@ -75,12 +75,22 @@ val SlimItemFields =
*/
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
/**
* [ItemFields] for the home page's rows. [ItemFields.PROVIDER_IDS] is needed so a focused movie or series has its TMDB id
* for [com.github.damontecres.wholphin.services.SeerrRatingsService] without another request.
*/
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.CAN_DELETE,
ItemFields.PROVIDER_IDS,
)
/**
* [SlimItemFields] for the home page's continue watching & next up rows, see [HomeItemFields]
*/
val HomeSlimItemFields = SlimItemFields + ItemFields.PROVIDER_IDS
val PhotoItemFields =
DetailItemFields +
listOf(

View file

@ -41,6 +41,7 @@ fun WatchedIconBackground(): Color =
when (LocalTheme.current) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
@ -55,6 +56,8 @@ fun WatchedIconBackground(): Color =
@Composable
fun WatchedIconColor(): Color =
when (LocalTheme.current) {
AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.BLUE,

View file

@ -251,7 +251,7 @@ fun CollectionFolderListDetails(
)
item?.let {
QuickDetails(item.ui.quickDetails, null)
QuickDetails(item, null)
}
OverviewText(

View file

@ -1,30 +1,24 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatTime
import com.github.damontecres.wholphin.ui.util.LocalClock
@ -32,6 +26,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import org.jellyfin.sdk.model.DateTime
import kotlin.time.Duration
/**
* The line of details under a title: year, runtime, etc, then the ratings, then when it ends.
*
* Ratings are only drawn for [ratings], never from the Jellyfin item. Pass the result of [rememberSourceRatings] or null
* for surfaces without ratings.
*/
@Composable
fun QuickDetails(
details: QuickDetailsData?,
@ -39,21 +39,16 @@ fun QuickDetails(
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
ratings: SourceRatings? = null,
) {
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
Row(modifier = modifier) {
if (details != null) {
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap)
QuickDetailsText(details.basic, Modifier, textStyle)
if (DisplayToggle.OFFICIAL_RATING in enabled) {
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap)
}
if (DisplayToggle.COMMUNITY_RATING in enabled) {
QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
}
if (DisplayToggle.CRITIC_RATING in enabled) {
QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
QuickDetailsText(details.officialRating, Modifier, textStyle)
}
SourceRatingsRow(ratings, Modifier, textStyle)
}
if (timeRemaining != null) {
TimeRemaining(timeRemaining, textStyle = textStyle)
@ -63,76 +58,41 @@ fun QuickDetails(
}
}
/**
* [QuickDetails] for a Jellyfin item with the ratings the PWA shows for it, if any (see [SeerrRatingsService]).
*/
@Composable
fun QuickDetails(
item: BaseItem,
timeRemaining: Duration?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
) = QuickDetails(
details = item.ui.quickDetails,
timeRemaining = timeRemaining,
modifier = modifier,
textStyle = textStyle,
endsAt = endsAt,
ratings = rememberSourceRatings(item),
)
@NonRestartableComposable
@Composable
fun QuickDetailsText(
str: AnnotatedString?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
inlineContentMap: Map<String, InlineTextContent> = rememberQuickDetailsContentMap(textStyle),
) = str?.let {
Text(
text = str,
color = MaterialTheme.colorScheme.onSurface,
style = textStyle,
inlineContent = inlineContentMap,
maxLines = 1,
modifier = modifier,
)
}
@Composable
fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typography.titleSmall) =
remember(textStyle) {
mapOf(
"star" to
InlineTextContent(
Placeholder(
textStyle.fontSize,
textStyle.fontSize,
PlaceholderVerticalAlign.TextCenter,
),
) {
Icon(
imageVector = Icons.Filled.Star,
tint = FilledStarColor,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
},
"rotten" to
InlineTextContent(
Placeholder(
textStyle.fontSize,
textStyle.fontSize,
PlaceholderVerticalAlign.TextCenter,
),
) {
Icon(
painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
tint = Color.Unspecified,
)
},
"fresh" to
InlineTextContent(
Placeholder(
textStyle.fontSize,
textStyle.fontSize,
PlaceholderVerticalAlign.TextCenter,
),
) {
Icon(
painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
tint = Color.Unspecified,
)
},
)
}
@Composable
fun TimeRemaining(
remaining: Duration,

View file

@ -1,93 +1,29 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.Locale
enum class StarRatingPrecision {
FULL,
HALF,
QUARTER,
;
companion object {
fun fromFloat(value: Float): StarRatingPrecision =
if (value <= .25f) {
QUARTER
} else if (value <= .5f) {
HALF
} else {
FULL
}
}
}
val FilledStarColor = Color(0xFFFFC700)
val EmptyStarColor = Color(0xFF5D4A0A)
val ratingBarHeight: Dp = 32.dp
@Composable
fun SimpleStarRating(
communityRating: Float?,
modifier: Modifier = Modifier,
) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
SimpleStarRating(
text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) },
modifier = modifier,
)
}
/**
* A minimum rating such as "7+" with a star, for the rating filter menus.
*
* This is not for showing a title's rating, see [SourceRatingsRow] for that.
*/
@Composable
fun SimpleStarRating(
text: String?,
@ -115,206 +51,3 @@ fun SimpleStarRating(
}
}
}
@Composable
fun TomatoRating(
rating: Float?,
modifier: Modifier = Modifier,
threshold: Float = 60f,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
if (rating != null) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Text(
text = rating.toInt().toString() + "%",
maxLines = 1,
modifier = Modifier,
)
}
val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() * .8f }
Icon(
painter =
painterResource(
if (rating >= threshold) {
R.drawable.ic_rotten_tomatoes_fresh
} else {
R.drawable.ic_rotten_tomatoes_rotten
},
),
contentDescription = null,
modifier = Modifier.height(height),
tint = Color.Unspecified,
)
}
}
}
/**
* Shows a rating out of 5 stars
*/
@Composable
fun StarRating(
rating100: Int,
onRatingChange: (Int) -> Unit,
precision: StarRatingPrecision,
enabled: Boolean,
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background,
) {
val context = LocalContext.current
var tempRating by remember(rating100) { mutableIntStateOf(rating100) }
val percentage = (if (enabled) tempRating else rating100) / 100f
val focusRequesters = remember { List(5) { FocusRequester() } }
Box(
modifier =
modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor),
) {
LazyRow(
modifier =
Modifier
.selectableGroup()
.padding(4.dp)
.drawWithCache {
onDrawWithContent {
drawContent()
if (percentage in 0f..<1f) {
drawRect(
color = bgColor,
topLeft = Offset(size.width * percentage, 0f),
blendMode = BlendMode.SrcAtop,
)
}
}
}.focusGroup()
.focusProperties {
onEnter = {
val index =
if (rating100 <= 20) {
0
} else if (rating100 <= 40) {
1
} else if (rating100 <= 60) {
2
} else if (rating100 <= 80) {
3
} else {
4
}
focusRequesters[index].tryRequestFocus()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
for (i in 1..5) {
item {
val isRated = (if (enabled) tempRating else rating100) >= (i * 20)
val icon = Icons.Filled.Star
var focused by remember { mutableStateOf(false) }
val focusedColor =
if (focused) {
MaterialTheme.colorScheme.border
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(focusedColor),
) {
Icon(
imageVector = icon,
tint = FilledStarColor,
contentDescription = null,
modifier =
if (enabled) {
Modifier
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) {
tempRating = i * 20
} else {
tempRating = rating100
}
}.playSoundOnFocus(playSoundOnFocus)
.focusRequester(focusRequesters[i - 1])
.focusProperties {
left =
if (i == 1) focusRequesters.last() else FocusRequester.Default
right =
if (i == 5) focusRequesters.first() else FocusRequester.Default
}.selectable(
selected = isRated,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
val newRating100 =
when (precision) {
StarRatingPrecision.FULL -> {
if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20
}
StarRatingPrecision.HALF -> {
if (rating100 > i * 20) {
i * 20
} else if (rating100 == i * 20) {
i * 20 - 10
} else if (rating100 == i * 20 - 10) {
(i - 1) * 20
} else if (i == 1 && rating100 == 0) {
20
} else if (i == 1 && rating100 > 10) {
10
} else if (i == 1 && rating100 <= 10) {
0
} else {
(i) * 20
}
}
StarRatingPrecision.QUARTER -> {
// TODO
null
}
}
if (newRating100 != null) {
tempRating = newRating100
onRatingChange(newRating100)
}
},
)
} else {
Modifier
}.fillMaxHeight()
.aspectRatio(1f),
)
}
}
}
}
}
}
@PreviewTvSpec
@Composable
private fun SimpleStarRatingPreview() {
WholphinTheme {
Column {
SimpleStarRating(7.5f, Modifier.height(32.dp))
TomatoRating(75f)
SimpleStarRating(7.5f, Modifier.height(20.dp))
SimpleStarRating("", Modifier.height(32.dp))
TomatoRating(75f)
TomatoRating(40f)
TomatoRating(3f)
}
}
}

View file

@ -143,6 +143,7 @@ fun sliderActiveColor(focused: Boolean): Color {
return when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
@ -179,6 +180,7 @@ fun sliderInactiveColor(focused: Boolean): Color {
return when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
-> {
MaterialTheme.colorScheme.border
.copy(alpha = .25f)

View file

@ -0,0 +1,152 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.RatingBadge
import com.github.damontecres.wholphin.data.model.RatingEntry
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
/**
* Provides the [SeerrRatingsService] to [rememberSourceRatings]
*/
val LocalSeerrRatingsService =
staticCompositionLocalOf<SeerrRatingsService> { throw IllegalStateException("LocalSeerrRatingsService not set") }
/**
* The ratings for an item from the same sources as the PWA. This is the only data path for ratings in the UI, every
* screen that shows ratings calls this and draws the result with [SourceRatingsRow] (usually via [QuickDetails]).
*
* Which items have ratings is decided by [SeerrRatingsService]. Returns null while loading, or if there are none, and
* never the previous item's ratings.
*/
@Composable
fun rememberSourceRatings(item: BaseItem?): SourceRatings? {
val service = LocalSeerrRatingsService.current
val key = item?.id
var ratings by remember(key) { mutableStateOf(item?.let { service.cachedFor(it) }) }
LaunchedEffect(key) {
// Leaving the composition or changing the item cancels this collection, and so a Seerr request that is still waiting or running
service.forItem(item).collect { ratings = it }
}
return ratings
}
@DrawableRes
fun RatingBadge.drawable(): Int =
when (this) {
RatingBadge.RT_FRESH -> R.drawable.ic_rotten_tomatoes_fresh
RatingBadge.RT_ROTTEN -> R.drawable.ic_rotten_tomatoes_rotten
RatingBadge.RT_UPRIGHT -> R.drawable.ic_rt_upright
RatingBadge.RT_SPILLED -> R.drawable.ic_rt_spilled
RatingBadge.RT_UNKNOWN -> R.drawable.ic_rotten_tomatoes_fresh
RatingBadge.IMDB -> R.drawable.ic_rating_imdb
RatingBadge.TMDB -> R.drawable.ic_rating_tmdb
}
/**
* The badges for the ratings as inline text content. IMDb and TMDB are wordmarks, wider than tall like in the PWA.
*/
@Composable
private fun rememberBadges(textStyle: TextStyle): Map<String, InlineTextContent> =
remember(textStyle) {
val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
RatingBadge.entries.associate { badge ->
val muted = badge == RatingBadge.RT_UNKNOWN
val placeholder = if (badge == RatingBadge.IMDB || badge == RatingBadge.TMDB) wide else square
badge.name to
InlineTextContent(placeholder) {
Image(
painter = painterResource(badge.drawable()),
contentDescription = null,
contentScale = ContentScale.Fit,
alpha = if (muted) 0.55f else 1f,
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* The text for the ratings: each as a badge and its number, separated by dots
*
* @param leadingDot whether a dot is put in front of the first rating because something precedes it
* @param episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score
*/
fun ratingsText(
entries: List<RatingEntry>,
leadingDot: Boolean,
episodeLabel: String,
): AnnotatedString =
buildAnnotatedString {
entries.forEachIndexed { index, entry ->
if (index > 0 || leadingDot) dot()
appendInlineContent(entry.badge.name)
append(" ")
if (entry.isEpisode) append("$episodeLabel ")
append(entry.text)
}
}
/**
* The one way ratings are drawn: badge and number pairs from [SourceRatings] in the PWA's order, Rotten Tomatoes then IMDb or TMDB.
*
* Respects the community rating (IMDb/TMDB) and critic rating (Rotten Tomatoes) display settings. Draws nothing if
* there is nothing to show.
*/
@Composable
fun SourceRatingsRow(
ratings: SourceRatings?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
leadingDot: Boolean = true,
) {
if (ratings == null) return
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val community = DisplayToggle.COMMUNITY_RATING in enabled
val critic = DisplayToggle.CRITIC_RATING in enabled
val episodeLabel = stringResource(R.string.rating_episode_label)
val text =
remember(ratings, community, critic, leadingDot, episodeLabel) {
ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) }
} ?: return
val badges = rememberBadges(textStyle)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
style = textStyle,
inlineContent = badges,
maxLines = 1,
modifier = modifier,
)
}

View file

@ -38,7 +38,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -183,7 +183,7 @@ fun DiscoverMovieDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
movie: MovieDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
canCancel: Boolean,
people: List<DiscoverItem>,
trailers: List<Trailer>,

View file

@ -20,14 +20,15 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -44,7 +45,7 @@ import kotlin.time.Duration.Companion.minutes
fun DiscoverMovieDetailsHeader(
preferences: UserPreferences,
movie: MovieDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
showLogo: Boolean,
@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader(
) {
val resources = LocalResources.current
val details =
remember(movie, rating, resources) {
remember(movie, resources) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
@ -105,15 +106,11 @@ fun DiscoverMovieDetailsHeader(
?.takeIf { it.isNotNullOrBlank() }
?.let(::add)
}.let {
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
)
QuickDetailsData(listToDotString(it))
}
}
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
}

View file

@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.RequestStatus
import com.github.damontecres.wholphin.data.model.SeerrItemType
@ -19,9 +19,11 @@ import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TmdbVote
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
@ -60,6 +62,7 @@ class DiscoverMovieViewModel
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val seerrServerRepository: SeerrServerRepository,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@ -108,11 +111,11 @@ class DiscoverMovieViewModel
viewModelScope.launchIO {
val rating =
getDiscoverRating(item.id) {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id),
)
}
ratingsService.get(
SeerrItemType.MOVIE,
item.id,
TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()),
)
_state.update { it.copy(rating = rating) }
}
if (state.value.similar.isEmpty()) {
@ -279,7 +282,7 @@ fun canUserCancelRequest(
data class DiscoverMovieState(
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val rating: SourceRatings? = null,
val seasons: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),
val people: List<DiscoverItem> = emptyList(),

View file

@ -34,7 +34,8 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.canRequestTv
@ -54,7 +55,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -214,7 +215,7 @@ fun DiscoverSeriesDetailsContent(
userConfig: SeerrUserConfig?,
series: TvDetails,
availability: SeerrAvailability,
rating: DiscoverRating?,
rating: SourceRatings?,
canCancel: Boolean,
seasons: List<RequestSeason>,
similar: List<DiscoverItem>,
@ -423,7 +424,7 @@ fun DiscoverSeriesDetailsContent(
@Composable
fun DiscoverSeriesDetailsHeader(
series: TvDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
overviewOnClick: () -> Unit,
showLogo: Boolean,
modifier: Modifier = Modifier,
@ -454,7 +455,7 @@ fun DiscoverSeriesDetailsHeader(
) {
val resources = LocalResources.current
val details =
remember(series, rating, resources) {
remember(series, resources) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime
@ -465,15 +466,11 @@ fun DiscoverSeriesDetailsHeader(
?.let { add(resources.formatDuration(it)) }
// TODO
}.let {
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
)
QuickDetailsData(listToDotString(it))
}
}
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
}

View file

@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutReques
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.RequestStatus
import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.TmdbVote
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -60,6 +62,7 @@ class DiscoverSeriesViewModel
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val seerrServerRepository: SeerrServerRepository,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@ -113,9 +116,11 @@ class DiscoverSeriesViewModel
viewModelScope.launchIO {
val rating =
getDiscoverRating(item.id) {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
}
ratingsService.get(
SeerrItemType.TV,
item.id,
TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()),
)
_state.update { it.copy(rating = rating) }
}
if (state.value.similar.isEmpty()) {
@ -449,7 +454,7 @@ class DiscoverSeriesViewModel
data class DiscoverSeriesState(
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val rating: SourceRatings? = null,
val seasons: List<RequestSeason> = emptyList(),
val seasons4k: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),

View file

@ -1,39 +1,16 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.services.jellyfinIdAsString
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.CancellationException
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
suspend fun getDiscoverRating(
id: Int,
func: suspend () -> DiscoverRating,
): DiscoverRating? =
try {
func.invoke()
} catch (ex: CancellationException) {
throw ex
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
Timber.w("No rating for %s", id)
} else {
Timber.e(ex, "Error fetching rating for %s", id)
}
null
} catch (ex: Exception) {
Timber.e(ex, "Error fetching rating for %s", id)
null
}
/**
* Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId]
*

View file

@ -55,7 +55,7 @@ fun EpisodeDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 8.dp
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
QuickDetails(ep, ep.timeRemainingOrRuntime)
VideoStreamDetails(
chosenStreams = chosenStreams,

View file

@ -64,7 +64,7 @@ fun MovieDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
movie.ui.quickDetails,
movie,
movie.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding),
)

View file

@ -300,6 +300,7 @@ data class MovieState(
val extras: List<ExtrasItem> = emptyList(),
val similar: List<BaseItem> = emptyList(),
val discovered: List<DiscoverItem> = emptyList(),
/** Seerr ratings, null until they load or if unavailable */
val chosenStreams: ChosenStreams? = null,
val canDelete: Boolean = false,
) {

View file

@ -35,10 +35,10 @@ fun FocusedEpisodeHeader(
) {
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
ep?.ui?.quickDetails?.let {
ep?.let {
QuickDetails(
it,
ep.timeRemainingOrRuntime,
it.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding),
)
}

View file

@ -674,7 +674,7 @@ fun SeriesDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
series.ui.quickDetails,
series,
null,
Modifier.padding(start = HeaderUtils.startPadding),
)

View file

@ -890,5 +890,6 @@ data class SeriesState(
val peopleInEpisode: PeopleInItem = PeopleInItem(),
val discovered: List<DiscoverItem> = emptyList(),
val discoverSeries: DiscoverItem? = null,
/** Seerr ratings, null until they load or if unavailable */
val chosenStreams: ChosenStreams? = null,
)

View file

@ -32,17 +32,17 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
@ -57,9 +57,9 @@ import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.successValue
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.UUID
@ -73,11 +73,12 @@ class SeerrDiscoverViewModel
constructor(
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
val navigationManager: NavigationManager,
private val backdropService: BackdropService,
) : ViewModel() {
val state = MutableStateFlow<DiscoverState>(DiscoverState())
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
val rating = MutableStateFlow<Map<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf())
init {
viewModelScope.launchIO {
@ -246,50 +247,23 @@ class SeerrDiscoverViewModel
}
}
private val ratingCache =
CacheBuilder
.newBuilder()
.maximumSize(100)
.build<Int, DiscoverRating>()
private var ratingJob: Job? = null
// TODO this is not very efficient
/**
* Fetch the ratings for the focused item, cancelling any previous request that is still running
*/
fun fetchRating(
id: Int,
type: SeerrItemType,
) {
viewModelScope.launchIO {
val cachedResult = ratingCache.getIfPresent(id)
if (cachedResult != null) {
return@launchIO
val key = type to id
if (key in rating.value) return
ratingJob?.cancel()
ratingJob =
viewModelScope.launchIO {
val result = ratingsService.get(type, id) ?: return@launchIO
rating.update { it + (key to result) }
}
val result =
getDiscoverRating(id) {
when (type) {
SeerrItemType.MOVIE -> {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id),
)
}
SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id))
}
SeerrItemType.PERSON -> {
DiscoverRating(null, null)
}
SeerrItemType.UNKNOWN -> {
DiscoverRating(null, null)
}
}
} ?: DiscoverRating(null, null)
ratingCache.put(id, result)
rating.update {
ratingCache.asMap().toMap()
}
}
}
}
@ -415,7 +389,7 @@ fun SeerrDiscoverPage(
modifier = modifier,
) {
val details =
remember(focusedItem, ratingMap) {
remember(focusedItem) {
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
buildList {
focusedItem.item
@ -424,23 +398,18 @@ fun SeerrDiscoverPage(
?.toString()
?.let(::add)
}.let {
val rating = focusedItem.id.let { ratingMap[it] }
val str =
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
)
QuickDetailsData(str)
QuickDetailsData(listToDotString(it))
}
}
}
val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] }
HomePageHeader(
title = focusedItem?.title?.getString(),
subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview,
overviewTwoLines = false,
quickDetails = details,
ratings = focusedRatings,
timeRemaining = null,
endsAt = null,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,

View file

@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
@ -72,6 +73,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
import com.github.damontecres.wholphin.ui.components.rememberSourceRatings
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -571,6 +573,7 @@ fun HomePageHeader(
overview = item?.data?.overview,
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails,
ratings = rememberSourceRatings(item),
timeRemaining = item?.timeRemainingOrRuntime,
endsAt = item?.data?.endDate,
showLogo = showLogo,
@ -586,6 +589,7 @@ fun HomePageHeader(
overview: String?,
overviewTwoLines: Boolean,
quickDetails: QuickDetailsData?,
ratings: SourceRatings?,
timeRemaining: Duration?,
endsAt: DateTime?,
showLogo: Boolean,
@ -611,7 +615,7 @@ fun HomePageHeader(
if (subtitle != null) {
EpisodeName(subtitle)
}
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt)
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings)
val overviewModifier =
Modifier
.padding(0.dp)

View file

@ -771,6 +771,7 @@ fun navItemColor(
when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,

View file

@ -717,6 +717,7 @@ class PlaybackViewModel
api.videosApi.getVideoStreamUrl(
itemId = itemId,
mediaSourceId = source.id,
liveStreamId = source.liveStreamId,
static = true,
tag = source.eTag,
playSessionId = response.playSessionId,

View file

@ -298,6 +298,9 @@ fun PreferencesContent(
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
if (group.title == R.string.about) {
WolfskinAbout()
}
}
val groupPreferences =
group.preferences +

View file

@ -70,6 +70,7 @@ fun SwitchColors(): SwitchColors {
return when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
-> {
SwitchDefaults.colors()

View file

@ -0,0 +1,28 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.components.TextButton
/** Fork identity and a focusable link to the upstream project. */
@Composable
fun WolfskinAbout() {
val uriHandler = LocalUriHandler.current
val upstreamUrl = stringResource(R.string.wolfskin_upstream_url)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.app_name_long),
style = MaterialTheme.typography.headlineMedium,
)
TextButton(onClick = { uriHandler.openUri(upstreamUrl) }) {
Text(stringResource(R.string.wolfskin_upstream_credit))
}
}
}

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@ -88,19 +89,24 @@ fun UserList(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val focusRequester = remember { FocusRequester() }
val firstFocusRequester = remember { FocusRequester() }
if (users.isNotEmpty()) {
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val listState = rememberLazyListState()
val initialFocusRequester = remember { FocusRequester() }
val initialIndex =
users.indexOfFirst { it.user.id == currentUser?.id }.takeIf { it >= 0 } ?: 0
LaunchedEffect(users, currentUser?.id) {
if (users.isNotEmpty()) {
listState.scrollToItem(initialIndex)
initialFocusRequester.tryRequestFocus()
}
}
LazyRow(
state = listState,
horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale
modifier =
Modifier
.wrapContentWidth()
.focusRestorer(firstFocusRequester)
.focusRequester(focusRequester),
.focusRestorer(initialFocusRequester),
) {
itemsIndexed(users) { index, user ->
UserIconCard(
@ -108,7 +114,7 @@ fun UserList(
isCurrentUser = user.user.id == currentUser?.id,
onClick = { onSwitchUser.invoke(user.user) },
onLongClick = { showDeleteDialog = user },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier,
)
}
// Add User card - always rightmost

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.seerrUrlPrefill
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -55,13 +56,13 @@ fun AddSeerrServerApiKey(
.padding(16.dp)
.wrapContentSize(),
) {
var url by remember { mutableStateOf(initialUrl) }
var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
var apiKey by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (initialUrl.isBlank()) {
if (url.isBlank()) {
focusRequester.tryRequestFocus()
} else {
passwordFocusRequester.tryRequestFocus()
@ -174,7 +175,7 @@ fun AddSeerrServerUsername(
.padding(16.dp)
.wrapContentSize(),
) {
var url by remember { mutableStateOf(initialUrl) }
var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
var username by remember { mutableStateOf(username) }
var password by remember { mutableStateOf("") }

View file

@ -225,10 +225,6 @@ fun ChooseSeerrLoginType(
title = stringResource(R.string.seerr_login),
items =
listOf(
DialogItem(
text = stringResource(R.string.api_key),
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
),
DialogItem(
text = stringResource(R.string.seerr_jellyfin_user),
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
@ -237,6 +233,10 @@ fun ChooseSeerrLoginType(
text = stringResource(R.string.seerr_local_user),
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
),
DialogItem(
text = stringResource(R.string.api_key),
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
),
),
)

View file

@ -13,9 +13,10 @@ import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
val LocalTheme =
compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE }
compositionLocalOf<AppThemeColors> { AppThemeColors.WOLFSKIN }
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
when (appThemeColors) {
@ -27,13 +28,14 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
AppThemeColors.RED -> RedThemeColors
AppThemeColors.BROWN -> BrownThemeColors
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
AppThemeColors.WOLFSKIN -> WolfskinThemeColors
AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors
}
@Composable
fun WholphinTheme(
darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN,
content: @Composable () -> Unit,
) {
val themeColors = getThemeColors(appThemeColors)

View file

@ -0,0 +1,59 @@
package com.github.damontecres.wholphin.ui.theme.colors
import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.ui.theme.ThemeColors
/** Lilac accents on near-black surfaces, with upstream Purple available separately. */
val WolfskinThemeColors =
object : ThemeColors {
private val background = Color(0xFF07070A)
private val lilac = Color(0xFFD2BCFF)
private val onLilac = Color(0xFF241334)
private val container = Color(0xFF382A49)
private val onContainer = Color(0xFFEADCFF)
private val surfaceVariant = Color(0xFF24202D)
override val lightSchemeMaterial = PurpleThemeColors.lightSchemeMaterial
override val lightScheme = PurpleThemeColors.lightScheme
override val darkSchemeMaterial =
PurpleThemeColors.darkSchemeMaterial.copy(
primary = lilac,
onPrimary = onLilac,
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = lilac,
onSecondary = onLilac,
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = lilac,
onTertiary = onLilac,
tertiaryContainer = container,
onTertiaryContainer = onContainer,
background = background,
surface = background,
surfaceVariant = surfaceVariant,
inversePrimary = Color(0xFF705090),
)
override val darkScheme =
PurpleThemeColors.darkScheme.copy(
primary = lilac,
onPrimary = onLilac,
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = lilac,
onSecondary = onLilac,
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = lilac,
onTertiary = onLilac,
tertiaryContainer = container,
onTertiaryContainer = onContainer,
background = background,
surface = background,
surfaceVariant = surfaceVariant,
inversePrimary = Color(0xFF705090),
border = lilac.copy(alpha = .75f),
)
}

View file

@ -436,7 +436,7 @@ val requestSerializersModule =
subclass(GetStudiosRequest::class)
subclass(GetRecordingsRequest::class)
subclass(GetLiveTvChannelsRequest::class)
subclass(GetArtistsHandler::class)
subclass(GetAlbumArtistsHandler::class)
subclass(GetArtistsRequest::class)
subclass(GetAlbumArtistsRequest::class)
}
}

View file

@ -128,6 +128,7 @@ enum AppThemeColors {
OLED_BLACK = 5;
RED = 6;
BROWN = 7;
WOLFSKIN = 8;
}
enum EdgeStyle{

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="575dp"
android:height="289.83dp"
android:viewportWidth="575"
android:viewportHeight="289.83">
<path
android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"
android:fillColor="#f6c700"/>
<path
android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"
android:fillColor="#000000"/>
<path
android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"
android:fillColor="#000000"/>
<path
android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"
android:fillColor="#000000"/>
<path
android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="185.04dp"
android:height="133.4dp"
android:viewportWidth="185.04"
android:viewportHeight="133.4">
<path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7">
<item android:offset="0" android:color="#90cea1"/>
<item android:offset="0.56" android:color="#3cbec9"/>
<item android:offset="1" android:color="#00b3e5"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="143.75dp"
android:height="108.75dp"
android:viewportWidth="143.75"
android:viewportHeight="108.75">
<path
android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z"
android:fillColor="#07a23b"/>
<path
android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"
android:fillColor="#fff"/>
<path
android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"
android:fillColor="#fff"/>
<path
android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"
android:fillColor="#fff"/>
<path
android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"
android:fillColor="#fff"/>
<path
android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z"
android:fillColor="#03621e"/>
<path
android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"
android:fillColor="#fdd600"/>
<path
android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"
android:fillColor="#fdd600"/>
<path
android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"
android:fillColor="#fdd600"/>
<path
android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"
android:fillColor="#fdd600"/>
<path
android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"
android:fillColor="#fdd600"/>
<path
android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"
android:fillColor="#fdd600"/>
<path
android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"
android:fillColor="#fdd600"/>
<path
android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"
android:fillColor="#fdd600"/>
<path
android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"
android:fillColor="#fdd600"/>
<path
android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"
android:fillColor="#fdd600"/>
<path
android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z"
android:fillColor="#09a339"/>
</vector>

View file

@ -0,0 +1,52 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="106.25dp"
android:height="140dp"
android:viewportWidth="106.25"
android:viewportHeight="140">
<path
android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"
android:fillColor="#fa3106"/>
<path
android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"
android:fillColor="#fff"/>
<path
android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"
android:fillColor="#ffd600"/>
</vector>

View file

@ -1,9 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/>
<foreground>
<inset
android:inset="18%"
android:drawable="@mipmap/ic_banner_foreground"/>
</foreground>
</adaptive-icon>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_banner_foreground"
android:gravity="fill" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

View file

@ -489,8 +489,8 @@
<string name="content_scale_fill">Vyplnit</string>
<string name="content_scale_fill_width">Vyplnit šířku</string>
<string name="content_scale_fill_height">Vyplnit výšku</string>
<string name="display_preset_default">Výchozí předvolba Wholphin</string>
<string name="display_preset_compact">Wholphin kompaktní</string>
<string name="display_preset_default">Výchozí předvolba Wolfskin</string>
<string name="display_preset_compact">Wolfskin kompaktní</string>
<string name="display_preset_series_thumb">Obrázky náhledů seriálů</string>
<string name="display_preset_episode_thumbnails">Obrázky náhledů epizod</string>
<string name="volume_lowest">Nejnižší</string>

View file

@ -538,8 +538,8 @@
<string name="content_scale_fill">Fyld</string>
<string name="content_scale_fill_width">Fyld bredde</string>
<string name="content_scale_fill_height">Fyld højde</string>
<string name="display_preset_default">Wholphin-standard</string>
<string name="display_preset_compact">Wholphin kompakt</string>
<string name="display_preset_default">Wolfskin-standard</string>
<string name="display_preset_compact">Wolfskin kompakt</string>
<string name="display_preset_series_thumb">Seriens miniaturebilleder</string>
<string name="display_preset_episode_thumbnails">Episode-miniaturebilleder</string>
<string name="volume_lowest">Laveste</string>
@ -637,7 +637,7 @@
<string name="select_all">Vælg alle</string>
<string name="separate_types">Adskil typer</string>
<string name="prefer_logos">Foretræk logoer som titler</string>
<string name="updated_toast">Wholphin er opdateret til %s</string>
<string name="updated_toast">Wolfskin er opdateret til %s</string>
<string name="remove_continue_watching">Fjern fra Fortsæt med at se</string>
<string name="remove_next_up">Fjern serien fra Næste</string>
<string name="view_removed_next_up">Vis serier fjernet fra Næste</string>

View file

@ -511,8 +511,8 @@
<string name="animate">Animieren</string>
<string name="content_scale_fill_width">Breite ausfüllen</string>
<string name="content_scale_fill_height">Höhe ausfüllen</string>
<string name="display_preset_default">Wholphin Standard</string>
<string name="display_preset_compact">Wholphin Kompakt</string>
<string name="display_preset_default">Wolfskin Standard</string>
<string name="display_preset_compact">Wolfskin Kompakt</string>
<string name="volume_lowest">Niedrigste</string>
<string name="volume_low">Niedrig</string>
<string name="volume_medium">Mittel</string>
@ -580,7 +580,7 @@
<string name="continue_string">Fortfahren</string>
<string name="separate_types">Verschiedene Arten</string>
<string name="prefer_logos">Bevorzugt Logo statt Titel anzeigen</string>
<string name="updated_toast">Wholphin aktualisiert auf %s</string>
<string name="updated_toast">Wolfskin aktualisiert auf %s</string>
<string name="remove_continue_watching">Von \"Weiterschauen\" entfernen</string>
<string name="remove_next_up">Serie aus \"Als Nächstes anschauen\" entfernen</string>
<string name="view_more">Mehr anzeigen</string>

View file

@ -599,7 +599,7 @@
<string name="remove_from_queue">Eliminar de la cola</string>
<string name="separate_types">Separar por tipos</string>
<string name="prefer_logos">Preferir logotipos para los títulos</string>
<string name="updated_toast">Wholphin se ha actualizado a la versión %s</string>
<string name="updated_toast">Wolfskin se ha actualizado a la versión %s</string>
<string name="view_more">Ver más</string>
<string name="discover_tv">Descubrir series</string>
<string name="discover_movies">Descubrir películas</string>

View file

@ -535,8 +535,8 @@
<string name="content_scale_fill">Täida</string>
<string name="content_scale_fill_width">Täida laiuses</string>
<string name="content_scale_fill_height">Täida kõrguses</string>
<string name="display_preset_default">Wholphini vaikeseadistus</string>
<string name="display_preset_compact">Wholphini kompaktne vaade</string>
<string name="display_preset_default">Wolfskini vaikeseadistus</string>
<string name="display_preset_compact">Wolfskini kompaktne vaade</string>
<string name="display_preset_series_thumb">Sarja pisipildid</string>
<string name="display_preset_episode_thumbnails">Osa pisipildid</string>
<string name="volume_lowest">Vaikseim</string>
@ -580,7 +580,7 @@
<string name="continue_string">Jätka</string>
<string name="separate_types">Eraldi tüübid</string>
<string name="prefer_logos">Pealkirjade puhul eelista logode kuvamist</string>
<string name="updated_toast">Wholphin on uuendatud versioonini %s</string>
<string name="updated_toast">Wolfskin on uuendatud versioonini %s</string>
<string name="remove_continue_watching">Eemalda „Jätka vaatamist“ valikust</string>
<string name="remove_next_up">Eemalda sari valikust „Järgmisena esitamisel“</string>
<string name="view_removed_next_up">Vaata sarja, mis sai eemaldatud valikust „Järgmisena esitamisel“</string>
@ -758,4 +758,5 @@
<string name="error">Viga</string>
<string name="convert_dovi_profile_7">Teisenda Dolby Vision Profile 7 → 8.1</string>
<string name="convert_dovi_profile_7_summary">Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7</string>
<string name="artist_source">Esitaja allikas</string>
</resources>

View file

@ -507,8 +507,8 @@
<string name="content_scale_fill">Remplir</string>
<string name="content_scale_fill_width">Largeur de remplissage</string>
<string name="content_scale_fill_height">Hauteur de remplissage</string>
<string name="display_preset_default">Wholphin Par défaut</string>
<string name="display_preset_compact">Wholphin Compact</string>
<string name="display_preset_default">Wolfskin Par défaut</string>
<string name="display_preset_compact">Wolfskin Compact</string>
<string name="display_preset_series_thumb">Images miniatures de la série</string>
<string name="display_preset_episode_thumbnails">Images miniatures de l\'épisode</string>
<string name="volume_lowest">Le plus bas</string>
@ -599,7 +599,7 @@
<string name="continue_string">Suivant</string>
<string name="separate_types">Séparer par types</string>
<string name="prefer_logos">Préférer les logos pour les titres</string>
<string name="updated_toast">Wholphin a été mis à jour vers la version %s</string>
<string name="updated_toast">Wolfskin a été mis à jour vers la version %s</string>
<string name="view_more">Voir plus</string>
<string name="discover_tv">Parcourir les séries</string>
<string name="discover_movies">Parcourir les films</string>
@ -777,4 +777,9 @@
<string name="home_settings_source_server_profile">Profil utilisateur du serveur</string>
<string name="home_settings_source_plugin">Paramètres par défaut du serveur</string>
<string name="home_settings_source_web">Client Web utilisateur</string>
<string name="convert_dovi_profile_7">Convertir le profil Dolby Vision 7 en 8.1</string>
<string name="convert_dovi_profile_7_summary">Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7</string>
<string name="artist_source">Source de l\'artiste</string>
<string name="managed_by_server">Géré par le serveur</string>
<string name="error">Erreur</string>
</resources>

View file

@ -593,8 +593,8 @@
<string name="content_scale_fill">Ispuni</string>
<string name="content_scale_fill_width">Ispuni širinu</string>
<string name="content_scale_fill_height">Ispuni visinu</string>
<string name="display_preset_default">Wholphin zadani</string>
<string name="display_preset_compact">Wholphin zbijeni</string>
<string name="display_preset_default">Wolfskin zadani</string>
<string name="display_preset_compact">Wolfskin zbijeni</string>
<string name="display_preset_series_thumb">Slike sličica serije</string>
<string name="display_preset_episode_thumbnails">Slike sličica epizoda</string>
<string name="volume_lowest">Najniža</string>
@ -653,7 +653,7 @@
<string name="select_all">Odaberi sve</string>
<string name="separate_types">Odvojene vrste</string>
<string name="prefer_logos">Preferiraj prikaz logotipa za naslove</string>
<string name="updated_toast">Wholphin ažuriran na %s</string>
<string name="updated_toast">Wolfskin ažuriran na %s</string>
<string name="remove_continue_watching">Ukloni iz \"Nastavi gledanje\"</string>
<string name="remove_next_up">Ukloni seriju iz \"Slijedi\"</string>
<string name="view_removed_next_up">Prikaži serije uklonjene iz \"Slijedi\"</string>

View file

@ -465,8 +465,8 @@
<string name="photos">Foto</string>
<string name="include_types">Sertakan jenis</string>
<string name="include_types_summary">Tampilkan gambar untuk jenis item</string>
<string name="display_preset_default">Wholphin Bawaan</string>
<string name="display_preset_compact">Wholphin Ringkas</string>
<string name="display_preset_default">Wolfskin Bawaan</string>
<string name="display_preset_compact">Wolfskin Ringkas</string>
<string name="display_preset_series_thumb">Gambar Thumbnail Seri</string>
<string name="display_preset_episode_thumbnails">Gambar Thumbnail Episode</string>
<string name="volume_lowest">Terendah</string>
@ -561,7 +561,7 @@
<string name="separate_types">Pisahkan jenis</string>
<string name="go_to_artist">Buka artis</string>
<string name="go_to_album">Buka album</string>
<string name="updated_toast">Wholphin diperbarui ke %s</string>
<string name="updated_toast">Wolfskin diperbarui ke %s</string>
<string name="remove_continue_watching">Hapus dari \"Lanjutkan menonton\"</string>
<string name="remove_next_up">Hapus seri dari \"Berikutnya\"</string>
<string name="view_removed_next_up">Lihat seri yang dihapus dari \"Berikutnya\"</string>

View file

@ -489,8 +489,8 @@
<string name="content_scale_fill">Riempi</string>
<string name="content_scale_fill_width">Riempi larghezza</string>
<string name="content_scale_fill_height">Riempi altezza</string>
<string name="display_preset_default">Wholphin predefinito</string>
<string name="display_preset_compact">Wholphin Compatto</string>
<string name="display_preset_default">Wolfskin predefinito</string>
<string name="display_preset_compact">Wolfskin Compatto</string>
<string name="display_preset_series_thumb">Immagini miniatura serie</string>
<string name="display_preset_episode_thumbnails">Immagini miniatura episodio</string>
<string name="volume_lowest">Minimo</string>
@ -599,7 +599,7 @@
<string name="continue_string">Continua</string>
<string name="separate_types">Tipi separati</string>
<string name="prefer_logos">Preferisci mostrare i loghi per i titoli</string>
<string name="updated_toast">Wholphin aggiornato a %s</string>
<string name="updated_toast">Wolfskin aggiornato a %s</string>
<string name="remove_continue_watching">Rimuovi da Continua a guardare</string>
<string name="remove_next_up">Rimuovi serie da Prossimo</string>
<string name="view_removed_next_up">Visualizza serie rimosse da Prossimo</string>

View file

@ -517,8 +517,8 @@
<string name="content_scale_fill">מתיחה לתצוגה</string>
<string name="content_scale_fill_width">מתיחה לרוחב</string>
<string name="content_scale_fill_height">מתיחה לגובה</string>
<string name="display_preset_default">ברירת המחדל של Wholphin</string>
<string name="display_preset_compact">Wholphin קומפקטי</string>
<string name="display_preset_default">ברירת המחדל של Wolfskin</string>
<string name="display_preset_compact">Wolfskin קומפקטי</string>
<string name="display_preset_series_thumb">תמונות־ממוזערות של סדרה</string>
<string name="display_preset_episode_thumbnails">תמונות־ממוזערות של פרק</string>
<string name="bold_blue">כחול עז</string>
@ -594,7 +594,7 @@
<string name="select_all">בחר הכול</string>
<string name="separate_types">סוגים נפרדים</string>
<string name="prefer_logos">העדף להציג לוגו על פני כותרת</string>
<string name="updated_toast">Wholphin עודכן ל־%s</string>
<string name="updated_toast">Wolfskin עודכן ל־%s</string>
<string name="remove_continue_watching">מחק מהמשך צפייה</string>
<string name="remove_next_up">מחק סדרה מ־\"הבא בתור\"</string>
<string name="view_removed_next_up">הצג סדרות שנמחקו מ־\"הבא בתור\"</string>

View file

@ -1,3 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>
</resources>

View file

@ -483,8 +483,8 @@
<string name="content_scale_fill">Fyll</string>
<string name="content_scale_fill_width">Fyll bredde</string>
<string name="content_scale_fill_height">Fyll høyde</string>
<string name="display_preset_default">Wholphin Standard</string>
<string name="display_preset_compact">Wholphin Kompakt</string>
<string name="display_preset_default">Wolfskin Standard</string>
<string name="display_preset_compact">Wolfskin Kompakt</string>
<string name="display_preset_series_thumb">Serieminiatyrbilder</string>
<string name="display_preset_episode_thumbnails">Episodeminiatyrbilder</string>
<string name="volume_lowest">Lavest</string>
@ -573,7 +573,7 @@
<string name="select_all">Velg alle</string>
<string name="separate_types">Seperate typer</string>
<string name="prefer_logos">Foretrekk å vise logoer for titler</string>
<string name="updated_toast">Wholphin oppdatert til %s</string>
<string name="updated_toast">Wolfskin oppdatert til %s</string>
<string name="remove_continue_watching">Fjern fra fortsett å se</string>
<string name="remove_next_up">Fjern serier fra neste</string>
<string name="view_removed_next_up">Vis serier fjernet fra neste</string>

View file

@ -480,8 +480,8 @@
<string name="content_scale_fill">Vullen</string>
<string name="content_scale_fill_width">Vulbreedte</string>
<string name="content_scale_fill_height">Vulhoogte</string>
<string name="display_preset_default">Wholphin standaard</string>
<string name="display_preset_compact">Wholphin compact</string>
<string name="display_preset_default">Wolfskin standaard</string>
<string name="display_preset_compact">Wolfskin compact</string>
<string name="volume_low">Laag</string>
<string name="volume_medium">Middel</string>
<string name="volume_high">Hoog</string>
@ -603,7 +603,7 @@
<string name="confirm_enable_experimental_title">Experimentele instellingen inschakelen?</string>
<string name="experimental_settings">Experimentele instellingen</string>
<string name="search_and_download_subtitles"><![CDATA[Zoek &amp; download ondertiteling]]></string>
<string name="updated_toast">Wholphin is geüpdatet naar %s</string>
<string name="updated_toast">Wolfskin is geüpdatet naar %s</string>
<string name="last_played">Laatst afgespeeld</string>
<string name="ass_subtitle_mode_exoplayer">Direct afspelen met de ingebouwde ExoPlayer</string>
<string name="ass_subtitle_mode_transcode">Ondertitels inbranden/transcoderen op de server</string>

View file

@ -478,8 +478,8 @@
<string name="content_scale_fill">Fyll</string>
<string name="content_scale_fill_width">Fyll breidde</string>
<string name="content_scale_fill_height">Fyll høgde</string>
<string name="display_preset_default">Wholphin-standard</string>
<string name="display_preset_compact">Wholphin-kompakt</string>
<string name="display_preset_default">Wolfskin-standard</string>
<string name="display_preset_compact">Wolfskin-kompakt</string>
<string name="display_preset_series_thumb">Miniatyrbilete for seriar</string>
<string name="display_preset_episode_thumbnails">Miniatyrbilete for episodar</string>
<string name="volume_lowest">Lågast</string>

View file

@ -386,8 +386,8 @@
<string name="content_scale_fill">Wypełnij</string>
<string name="content_scale_fill_width">Pełna szerokość</string>
<string name="content_scale_fill_height">Pełna wysokość</string>
<string name="display_preset_default">Domyślny Wholphin</string>
<string name="display_preset_compact">Kompaktowy Wholphin</string>
<string name="display_preset_default">Domyślny Wolfskin</string>
<string name="display_preset_compact">Kompaktowy Wolfskin</string>
<string name="display_preset_series_thumb">Miniatury seriali</string>
<string name="display_preset_episode_thumbnails">Zdjęcie odcinków</string>
<string name="volume_lowest">Najniższa</string>
@ -576,7 +576,7 @@
<string name="select_all">Wybierz wszystkie</string>
<string name="separate_types">Oddzielne typy</string>
<string name="prefer_logos">Wyświetlanie logo zamiast tytułu</string>
<string name="updated_toast">Wholphin zaktualizowany do %s</string>
<string name="updated_toast">Wolfskin zaktualizowany do %s</string>
<string name="remove_continue_watching">Usuń z Kontynuuj oglądanie</string>
<string name="remove_next_up">Usuń serial z Następnie</string>
<string name="view_removed_next_up">Zobacz serię usuniętą z Następnie</string>
@ -800,4 +800,9 @@
<string name="home_settings_source_server_profile">Profil użytkownika na serwerze</string>
<string name="home_settings_source_plugin">Domyślnie z serwera</string>
<string name="home_settings_source_web">Użytkownik klienta webowego</string>
<string name="convert_dovi_profile_7">Konwertuj Dolby Vision 7 do 8.1</string>
<string name="convert_dovi_profile_7_summary">W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie</string>
<string name="artist_source">Źródło artysty</string>
<string name="managed_by_server">Zarządzane przez serwer</string>
<string name="error">Błąd</string>
</resources>

View file

@ -498,8 +498,8 @@
<string name="content_scale_fill">Preencher</string>
<string name="content_scale_fill_width">Preencher largura</string>
<string name="content_scale_fill_height">Preencher altura</string>
<string name="display_preset_default">Padrão do Wholphin</string>
<string name="display_preset_compact">Wholphin Compacto</string>
<string name="display_preset_default">Padrão do Wolfskin</string>
<string name="display_preset_compact">Wolfskin Compacto</string>
<string name="display_preset_series_thumb">Prévia das imagens das séries</string>
<string name="display_preset_episode_thumbnails">Prévias das imagens do episódio</string>
<string name="volume_lowest">Mais baixo</string>
@ -605,7 +605,7 @@
<string name="select_all">Selecionar todos</string>
<string name="separate_types">Tipos separados</string>
<string name="prefer_logos">Preferir a exibição de logos para títulos</string>
<string name="updated_toast">Wholphin atualizado para %s</string>
<string name="updated_toast">Wolfskin atualizado para %s</string>
<string name="remove_continue_watching">Remover de continuar assistindo</string>
<string name="remove_next_up">Remover série de a seguir</string>
<string name="view_removed_next_up">Ver séries removidas de a seguir</string>

View file

@ -507,8 +507,8 @@
<string name="content_scale_fill">Preencher</string>
<string name="content_scale_fill_width">Preencher largura</string>
<string name="content_scale_fill_height">Preencher altura</string>
<string name="display_preset_default">Padrão do Wholphin</string>
<string name="display_preset_compact">Wholphin Compacto</string>
<string name="display_preset_default">Padrão do Wolfskin</string>
<string name="display_preset_compact">Wolfskin Compacto</string>
<string name="display_preset_series_thumb">Imagens em miniatura da série</string>
<string name="display_preset_episode_thumbnails">Imagens em miniatura do episódio</string>
<string name="volume_lowest">Mínimo</string>
@ -599,7 +599,7 @@
<string name="continue_string">Continuar</string>
<string name="separate_types">Separar por tipos</string>
<string name="prefer_logos">Prefiro mostrar logótipos nos títulos</string>
<string name="updated_toast">Wholphin foi atualizado para %s</string>
<string name="updated_toast">Wolfskin foi atualizado para %s</string>
<string name="remove_continue_watching">Remover de «Continuar a ver»</string>
<string name="remove_next_up">Remover série do «Continuar a ver»</string>
<string name="view_removed_next_up">Ver séries removidas do «Continuar a ver»</string>

View file

@ -605,8 +605,8 @@
<string name="content_scale_fill">Растянуть</string>
<string name="content_scale_fill_width">По ширине</string>
<string name="content_scale_fill_height">По высоте</string>
<string name="display_preset_default">Wholphin по умолчанию</string>
<string name="display_preset_compact">Wholphin компактный</string>
<string name="display_preset_default">Wolfskin по умолчанию</string>
<string name="display_preset_compact">Wolfskin компактный</string>
<string name="display_preset_series_thumb">Миниатюры сериалов</string>
<string name="display_preset_episode_thumbnails">Миниатюры эпизодов</string>
<string name="volume_lowest">Самая низкая</string>
@ -683,7 +683,7 @@
<string name="continue_string">Продолжить</string>
<string name="separate_types">Разделять по типам</string>
<string name="prefer_logos">Показывать логотипы вместо текстовых названий</string>
<string name="updated_toast">Wholphin обновлён до версии %s</string>
<string name="updated_toast">Wolfskin обновлён до версии %s</string>
<string name="remove_continue_watching">Убрать из «Продолжить просмотр»</string>
<string name="remove_next_up">Убрать сериал из «Далее»</string>
<string name="view_removed_next_up">Показать сериалы, убранные из «Далее»</string>

View file

@ -526,8 +526,8 @@
<string name="content_scale_fill">Vyplniť</string>
<string name="content_scale_fill_width">Vyplniť na šírku</string>
<string name="content_scale_fill_height">Vyplniť na výšku</string>
<string name="display_preset_default">Wholphin predvolené</string>
<string name="display_preset_compact">Wholphin kompaktné</string>
<string name="display_preset_default">Wolfskin predvolené</string>
<string name="display_preset_compact">Wolfskin kompaktné</string>
<string name="display_preset_series_thumb">Náhľadové obrázky seriálov</string>
<string name="display_preset_episode_thumbnails">Náhľadové obrázky epizód</string>
<string name="image_type_thumb">Náhľad</string>
@ -662,7 +662,7 @@
<string name="continue_string">Pokračovať</string>
<string name="separate_types">Oddeliť typy</string>
<string name="prefer_logos">Uprednostniť zobrazovanie log namiesto názvov</string>
<string name="updated_toast">Wholphin bol aktualizovaný na verziu %s</string>
<string name="updated_toast">Wolfskin bol aktualizovaný na verziu %s</string>
<string name="remove_continue_watching">Odstrániť z Pokračovať v pozeraní</string>
<string name="remove_next_up">Odstrániť seriál zo sekcie Nasleduje</string>
<string name="view_removed_next_up">Zobraziť seriály odstránené zo sekcie Nasleduje</string>

View file

@ -600,7 +600,7 @@
<string name="select_all">Izberi vse</string>
<string name="separate_types">Ločene vrste</string>
<string name="prefer_logos">Prikazuj logotipe za naslove</string>
<string name="updated_toast">Wholphin posodobljen na %s</string>
<string name="updated_toast">Wolfskin posodobljen na %s</string>
<string name="remove_continue_watching">Odstrani iz nadaljevanja gledanja</string>
<string name="remove_next_up">Odstrani serijo iz nadaljevanja gledanja</string>
<string name="view_removed_next_up">Ogled serije, odstranjene iz naslednjega gledanja</string>

View file

@ -424,8 +424,8 @@
<string name="customize_home_summary">Välj rader och bilder på startsidan</string>
<string name="delete_item">Är du säker på att du vill radera detta objekt?</string>
<string name="discover">Upptäck</string>
<string name="display_preset_compact">Wholphin Kompakt</string>
<string name="display_preset_default">Wholphin Standard</string>
<string name="display_preset_compact">Wolfskin Kompakt</string>
<string name="display_preset_default">Wolfskin Standard</string>
<string name="display_presets">Visningsförinställningar</string>
<string name="display_presets_description">Inbyggda förinställningar för att snabbt styla alla rader</string>
<string name="display_preset_episode_thumbnails">Avsnittsminiatyrer</string>
@ -595,7 +595,7 @@
<string name="include_types_summary">Vilka typer av objekt ska bilder visas för</string>
<string name="song_has_lyrics">Låten har text</string>
<string name="show_album_cover">Visa omslag till album</string>
<string name="updated_toast">Wholphin har uppdaterats till %s</string>
<string name="updated_toast">Wolfskin har uppdaterats till %s</string>
<string name="last_played">Senast visat</string>
<string name="root_folder">Huvudkatalog</string>
<string name="popularity">Populäritet</string>

View file

@ -465,8 +465,8 @@
<string name="content_scale_fill">ยืดเต็มจอ</string>
<string name="content_scale_fill_width">ยืดตามความกว้าง</string>
<string name="content_scale_fill_height">ยืดตามความสูง</string>
<string name="display_preset_default">Wholphin ค่าเริ่มต้น</string>
<string name="display_preset_compact">Wholphin คอมแพค</string>
<string name="display_preset_default">Wolfskin ค่าเริ่มต้น</string>
<string name="display_preset_compact">Wolfskin คอมแพค</string>
<string name="display_preset_series_thumb">ภาพขนาดย่อของซีรี่ส์</string>
<string name="display_preset_episode_thumbnails">ภาพขนาดย่อของตอน</string>
<string name="volume_lowest">เบาสุด</string>

View file

@ -480,8 +480,8 @@
<string name="content_scale_fill">Doldur</string>
<string name="content_scale_fill_width">Genişliği Doldur</string>
<string name="content_scale_fill_height">Yüksekliği Doldur</string>
<string name="display_preset_default">Wholphin Varsayılan</string>
<string name="display_preset_compact">Wholphin Kompakt</string>
<string name="display_preset_default">Wolfskin Varsayılan</string>
<string name="display_preset_compact">Wolfskin Kompakt</string>
<string name="display_preset_series_thumb">Dizi Küçük Resimleri</string>
<string name="display_preset_episode_thumbnails">Bölüm Küçük Resimleri</string>
<string name="volume_lowest">En Düşük</string>
@ -589,7 +589,7 @@
<string name="continue_string">Devam et</string>
<string name="separate_types">Farklı türler</string>
<string name="prefer_logos">Başlık yerine logo göster</string>
<string name="updated_toast">Wholphin %s sürümüne güncellendi</string>
<string name="updated_toast">Wolfskin %s sürümüne güncellendi</string>
<string name="view_more">Daha fazla göster</string>
<string name="discover_tv">Dizileri Keşfet</string>
<string name="discover_movies">Filmleri Keşfet</string>

View file

@ -567,7 +567,7 @@
<string name="content_scale_fill">Заповнити</string>
<string name="content_scale_fill_width">Заповнити по ширині</string>
<string name="content_scale_fill_height">Заповнити по висоті</string>
<string name="display_preset_default">Wholphin за замовчуванням</string>
<string name="display_preset_default">Wolfskin за замовчуванням</string>
<string name="display_preset_series_thumb">Мініатюри серіалів</string>
<string name="display_preset_episode_thumbnails">Мініатюри серій</string>
<string name="volume_lowest">Найнижча</string>
@ -655,7 +655,7 @@
<string name="select_all">Вибрати все</string>
<string name="separate_types">Розділяти типи</string>
<string name="prefer_logos">Надавати перевагу логотипам для назв</string>
<string name="updated_toast">Wholphin оновлено до %s</string>
<string name="updated_toast">Wolfskin оновлено до %s</string>
<string name="remove_continue_watching">Видалити з «Продовжити перегляд»</string>
<string name="remove_next_up">Видалити серіал з «Далі»</string>
<string name="view_removed_next_up">Переглянути серіали, видалені з «Далі»</string>
@ -686,5 +686,5 @@
<string name="tomatoes">Томати</string>
<string name="sort">Сортування</string>
<string name="split_into_separate_rows">Розділити на окремі рядки</string>
<string name="display_preset_compact">Wholphin компактний</string>
<string name="display_preset_compact">Wolfskin компактний</string>
</resources>

View file

@ -453,8 +453,8 @@
<string name="content_scale_fill">填充</string>
<string name="content_scale_fill_width">填充宽度</string>
<string name="content_scale_fill_height">填充高度</string>
<string name="display_preset_default">Wholphin 默认</string>
<string name="display_preset_compact">Wholphin 紧凑</string>
<string name="display_preset_default">Wolfskin 默认</string>
<string name="display_preset_compact">Wolfskin 紧凑</string>
<string name="display_preset_series_thumb">剧集缩略图</string>
<string name="display_preset_episode_thumbnails">分集缩略图</string>
<string name="purple">紫色</string>
@ -561,7 +561,7 @@
<string name="continue_string">继续</string>
<string name="separate_types">分离类型</string>
<string name="prefer_logos">优先显示标题图标</string>
<string name="updated_toast">Wholphin 已更新至 %s</string>
<string name="updated_toast">Wolfskin 已更新至 %s</string>
<string name="view_more">查看更多</string>
<string name="discover_tv">发现电视节目</string>
<string name="discover_movies">发现电影</string>

View file

@ -482,8 +482,8 @@
<string name="prefer_mpv">優先使用 MPV</string>
<string name="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</string>
<string name="favorite_items_title">我的最愛的 %s</string>
<string name="display_preset_default">Wholphin 預設樣式</string>
<string name="display_preset_compact">Wholphin 緊湊樣式</string>
<string name="display_preset_default">Wolfskin 預設樣式</string>
<string name="display_preset_compact">Wolfskin 緊湊樣式</string>
<string name="display_preset_series_thumb">劇集縮圖樣式</string>
<string name="display_preset_episode_thumbnails">單集縮圖樣式</string>
<string name="aspect_ratios_poster">海報 (2:3)</string>
@ -561,7 +561,7 @@
<string name="instant_mix">即時混播</string>
<string name="separate_types">依類型分開顯示</string>
<string name="prefer_logos">優先顯示圖像標題</string>
<string name="updated_toast">Wholphin 已更新至 %s</string>
<string name="updated_toast">Wolfskin 已更新至 %s</string>
<string name="view_more">查看更多</string>
<string name="discover_tv">探索電視劇</string>
<string name="discover_movies">探索電影</string>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_banner_background">#000B25</color>
</resources>

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#101010</color>
<color name="ic_launcher_background">#07070a</color>
</resources>

Some files were not shown because too many files have changed in this diff Show more