Compare commits
No commits in common. "670e95f2fa6f7d94c70d36cfbfb2e9d713697c92" and "3a8fa272f4e90186f02332f192d4a2e708b32c1c" have entirely different histories.
670e95f2fa
...
3a8fa272f4
18
Intents.md
|
|
@ -33,7 +33,7 @@ Full format
|
|||
```bash
|
||||
adb shell am start \
|
||||
-a android.intent.action.SEARCH \
|
||||
-n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n '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 'org.justinvisser.wolfskin.tv.debug/com.github.damontecres.wholphin.MainActivity' \
|
||||
-n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \
|
||||
--es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d"
|
||||
```
|
||||
|
|
|
|||
21
Makefile
|
|
@ -1,21 +0,0 @@
|
|||
.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
|
||||
12
README.md
|
|
@ -1,15 +1,3 @@
|
|||
# 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."
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ configure<ApplicationExtension> {
|
|||
compileSdk = libs.versions.compileSdk.getInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.justinvisser.wolfskin.tv"
|
||||
applicationId = "com.github.damontecres.wholphin"
|
||||
minSdk = libs.versions.minSdk.getInt()
|
||||
targetSdk = libs.versions.targetSdk.getInt()
|
||||
versionCode = gitTags.trim().lines().size
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Wolfskin (Debug)</string>
|
||||
<string name="app_name" translatable="false">Wholphin (Debug)</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 129 KiB |
|
|
@ -46,7 +46,6 @@ 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
|
||||
|
|
@ -60,13 +59,12 @@ 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.WolfskinThemeColors
|
||||
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -120,9 +118,6 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var imageUrlService: ImageUrlService
|
||||
|
||||
@Inject
|
||||
lateinit var seerrRatingsService: SeerrRatingsService
|
||||
|
||||
@Inject
|
||||
lateinit var refreshRateService: RefreshRateService
|
||||
|
||||
|
|
@ -234,7 +229,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
viewModel.appStart(intent)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) {
|
||||
MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
val userPreferences by userPreferencesService.flow.collectAsState(null)
|
||||
if (userPreferences == null) {
|
||||
|
|
@ -267,10 +262,7 @@ class MainActivity : AppCompatActivity() {
|
|||
LaunchedEffect(appPreferences.debugLogging) {
|
||||
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalImageUrlService provides imageUrlService,
|
||||
LocalSeerrRatingsService provides seerrRatingsService,
|
||||
) {
|
||||
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
||||
WholphinTheme(
|
||||
true,
|
||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
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
|
||||
|
|
@ -16,7 +16,7 @@ import java.util.UUID
|
|||
/**
|
||||
* Represents the status of the most recently used server
|
||||
*
|
||||
* This can be saved/retrieved from a [MostRecentServerProvider]
|
||||
* This can be read/written to shared preferences
|
||||
*/
|
||||
sealed interface MostRecentServer {
|
||||
/**
|
||||
|
|
@ -58,12 +58,12 @@ sealed interface MostRecentServer {
|
|||
|
||||
interface MostRecentServerProvider {
|
||||
/**
|
||||
* Get the [MostRecentServer] from this provider
|
||||
* Get the [MostRecentServer] from the shared preferences in [context]
|
||||
*/
|
||||
fun get(): MostRecentServer
|
||||
|
||||
/**
|
||||
* Save the [CurrentUser] as the most recent server
|
||||
* Save the [CurrentUser] as the most recent server into the shared preferences in [context]
|
||||
*/
|
||||
suspend fun save(current: CurrentUser)
|
||||
|
||||
|
|
@ -86,6 +86,9 @@ 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)
|
||||
|
|
@ -100,6 +103,9 @@ 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())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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
|
||||
|
|
@ -31,6 +32,7 @@ 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
|
||||
|
||||
|
|
@ -233,6 +235,26 @@ 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")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -348,6 +370,8 @@ data class BaseItemUi(
|
|||
data class QuickDetailsData(
|
||||
val basic: AnnotatedString,
|
||||
val officialRating: AnnotatedString? = null,
|
||||
val criticRating: AnnotatedString? = null,
|
||||
val communityRating: AnnotatedString? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
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
|
||||
|
|
@ -121,3 +123,20 @@ 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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -557,7 +557,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
val ThemeColors =
|
||||
AppChoicePreference<AppPreferences, AppThemeColors>(
|
||||
title = R.string.app_theme,
|
||||
defaultValue = AppThemeColors.WOLFSKIN,
|
||||
defaultValue = AppThemeColors.PURPLE,
|
||||
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://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
||||
defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest",
|
||||
getter = { it.updateUrl },
|
||||
setter = { prefs, value ->
|
||||
prefs.update { updateUrl = value }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.ui.HomeSlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
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 = HomeSlimItemFields,
|
||||
fields = SlimItemFields,
|
||||
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 = HomeSlimItemFields,
|
||||
fields = SlimItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
limit = limit,
|
||||
|
|
|
|||
|
|
@ -1,411 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ class UpdateChecker
|
|||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
companion object {
|
||||
const val ASSET_NAME = "Wolfskin"
|
||||
const val ASSET_NAME = "Wholphin"
|
||||
const val APK_NAME = "$ASSET_NAME.apk"
|
||||
|
||||
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||
|
|
@ -65,53 +65,6 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,29 +127,17 @@ 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 request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(RELEASES_URL)
|
||||
.get()
|
||||
.build()
|
||||
val url =
|
||||
"https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}"
|
||||
return withContext(WholphinDispatchers.IO) {
|
||||
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
|
||||
}
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
getRelease(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,11 +158,36 @@ class UpdateChecker
|
|||
private fun getRelease(request: Request): Release? {
|
||||
return okHttpClient.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG)
|
||||
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")
|
||||
}
|
||||
} else {
|
||||
Timber.w("Update check failed ${it.code}: ${it.message}")
|
||||
null
|
||||
}
|
||||
return@use null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
|
@ -241,12 +242,30 @@ val MediaSegmentType.skipStringRes: Int
|
|||
|
||||
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
|
||||
|
||||
fun listToDotString(strings: List<String>): AnnotatedString =
|
||||
fun listToDotString(
|
||||
strings: List<String>,
|
||||
communityRating: Float?,
|
||||
criticRating: Float?,
|
||||
): 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
|
||||
|
|
|
|||
|
|
@ -75,22 +75,12 @@ 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(
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ fun WatchedIconBackground(): Color =
|
|||
when (LocalTheme.current) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
@ -56,8 +55,6 @@ fun WatchedIconBackground(): Color =
|
|||
@Composable
|
||||
fun WatchedIconColor(): Color =
|
||||
when (LocalTheme.current) {
|
||||
AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary
|
||||
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.BLUE,
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ fun CollectionFolderListDetails(
|
|||
)
|
||||
|
||||
item?.let {
|
||||
QuickDetails(item, null)
|
||||
QuickDetails(item.ui.quickDetails, null)
|
||||
}
|
||||
|
||||
OverviewText(
|
||||
|
|
|
|||
|
|
@ -1,24 +1,30 @@
|
|||
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
|
||||
|
|
@ -26,12 +32,6 @@ 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,16 +39,21 @@ 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)
|
||||
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap)
|
||||
if (DisplayToggle.OFFICIAL_RATING in enabled) {
|
||||
QuickDetailsText(details.officialRating, Modifier, textStyle)
|
||||
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)
|
||||
}
|
||||
SourceRatingsRow(ratings, Modifier, textStyle)
|
||||
}
|
||||
if (timeRemaining != null) {
|
||||
TimeRemaining(timeRemaining, textStyle = textStyle)
|
||||
|
|
@ -58,41 +63,76 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,29 +1,93 @@
|
|||
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)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleStarRating(
|
||||
text: String?,
|
||||
|
|
@ -51,3 +115,206 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,7 +143,6 @@ fun sliderActiveColor(focused: Boolean): Color {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
@ -180,7 +179,6 @@ fun sliderInactiveColor(focused: Boolean): Color {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
-> {
|
||||
MaterialTheme.colorScheme.border
|
||||
.copy(alpha = .25f)
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
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: SourceRatings?,
|
||||
rating: DiscoverRating?,
|
||||
canCancel: Boolean,
|
||||
people: List<DiscoverItem>,
|
||||
trailers: List<Trailer>,
|
||||
|
|
|
|||
|
|
@ -20,15 +20,14 @@ 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.QuickDetailsData
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
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.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
|
||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||
import com.github.damontecres.wholphin.ui.formatDuration
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
|
@ -45,7 +44,7 @@ import kotlin.time.Duration.Companion.minutes
|
|||
fun DiscoverMovieDetailsHeader(
|
||||
preferences: UserPreferences,
|
||||
movie: MovieDetails,
|
||||
rating: SourceRatings?,
|
||||
rating: DiscoverRating?,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
overviewOnClick: () -> Unit,
|
||||
showLogo: Boolean,
|
||||
|
|
@ -80,7 +79,7 @@ fun DiscoverMovieDetailsHeader(
|
|||
) {
|
||||
val resources = LocalResources.current
|
||||
val details =
|
||||
remember(movie, resources) {
|
||||
remember(movie, rating, resources) {
|
||||
buildList {
|
||||
movie.releaseDate?.let(::add)
|
||||
movie.runtime
|
||||
|
|
@ -106,11 +105,15 @@ fun DiscoverMovieDetailsHeader(
|
|||
?.takeIf { it.isNotNullOrBlank() }
|
||||
?.let(::add)
|
||||
}.let {
|
||||
QuickDetailsData(listToDotString(it))
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
|
||||
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
|
||||
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
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,11 +19,9 @@ 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
|
||||
|
|
@ -62,7 +60,6 @@ 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() {
|
||||
|
|
@ -111,11 +108,11 @@ class DiscoverMovieViewModel
|
|||
|
||||
viewModelScope.launchIO {
|
||||
val rating =
|
||||
ratingsService.get(
|
||||
SeerrItemType.MOVIE,
|
||||
item.id,
|
||||
TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()),
|
||||
)
|
||||
getDiscoverRating(item.id) {
|
||||
DiscoverRating(
|
||||
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id),
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(rating = rating) }
|
||||
}
|
||||
if (state.value.similar.isEmpty()) {
|
||||
|
|
@ -282,7 +279,7 @@ fun canUserCancelRequest(
|
|||
|
||||
data class DiscoverMovieState(
|
||||
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
|
||||
val rating: SourceRatings? = null,
|
||||
val rating: DiscoverRating? = null,
|
||||
val seasons: List<RequestSeason> = emptyList(),
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
val people: List<DiscoverItem> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ 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.QuickDetailsData
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.canRequestTv
|
||||
|
|
@ -55,7 +54,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.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
|
||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
|
|
@ -215,7 +214,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
userConfig: SeerrUserConfig?,
|
||||
series: TvDetails,
|
||||
availability: SeerrAvailability,
|
||||
rating: SourceRatings?,
|
||||
rating: DiscoverRating?,
|
||||
canCancel: Boolean,
|
||||
seasons: List<RequestSeason>,
|
||||
similar: List<DiscoverItem>,
|
||||
|
|
@ -424,7 +423,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
@Composable
|
||||
fun DiscoverSeriesDetailsHeader(
|
||||
series: TvDetails,
|
||||
rating: SourceRatings?,
|
||||
rating: DiscoverRating?,
|
||||
overviewOnClick: () -> Unit,
|
||||
showLogo: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -455,7 +454,7 @@ fun DiscoverSeriesDetailsHeader(
|
|||
) {
|
||||
val resources = LocalResources.current
|
||||
val details =
|
||||
remember(series, resources) {
|
||||
remember(series, rating, resources) {
|
||||
buildList {
|
||||
series.firstAirDate?.let(::add)
|
||||
series.episodeRunTime
|
||||
|
|
@ -466,11 +465,15 @@ fun DiscoverSeriesDetailsHeader(
|
|||
?.let { add(resources.formatDuration(it)) }
|
||||
// TODO
|
||||
}.let {
|
||||
QuickDetailsData(listToDotString(it))
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
|
||||
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
|
||||
series.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
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,10 +18,8 @@ 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
|
||||
|
|
@ -62,7 +60,6 @@ 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() {
|
||||
|
|
@ -116,11 +113,9 @@ class DiscoverSeriesViewModel
|
|||
|
||||
viewModelScope.launchIO {
|
||||
val rating =
|
||||
ratingsService.get(
|
||||
SeerrItemType.TV,
|
||||
item.id,
|
||||
TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()),
|
||||
)
|
||||
getDiscoverRating(item.id) {
|
||||
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
|
||||
}
|
||||
_state.update { it.copy(rating = rating) }
|
||||
}
|
||||
if (state.value.similar.isEmpty()) {
|
||||
|
|
@ -454,7 +449,7 @@ class DiscoverSeriesViewModel
|
|||
|
||||
data class DiscoverSeriesState(
|
||||
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
|
||||
val rating: SourceRatings? = null,
|
||||
val rating: DiscoverRating? = null,
|
||||
val seasons: List<RequestSeason> = emptyList(),
|
||||
val seasons4k: List<RequestSeason> = emptyList(),
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,39 @@
|
|||
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]
|
||||
*
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fun EpisodeDetailsHeader(
|
|||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
val padding = 8.dp
|
||||
QuickDetails(ep, ep.timeRemainingOrRuntime)
|
||||
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
|
||||
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ fun MovieDetailsHeader(
|
|||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
QuickDetails(
|
||||
movie,
|
||||
movie.ui.quickDetails,
|
||||
movie.timeRemainingOrRuntime,
|
||||
Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -300,7 +300,6 @@ 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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ fun FocusedEpisodeHeader(
|
|||
) {
|
||||
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
|
||||
|
||||
ep?.let {
|
||||
ep?.ui?.quickDetails?.let {
|
||||
QuickDetails(
|
||||
it,
|
||||
it.timeRemainingOrRuntime,
|
||||
ep.timeRemainingOrRuntime,
|
||||
Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -674,7 +674,7 @@ fun SeriesDetailsHeader(
|
|||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
QuickDetails(
|
||||
series,
|
||||
series.ui.quickDetails,
|
||||
null,
|
||||
Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -890,6 +890,5 @@ 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,12 +73,11 @@ 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<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf())
|
||||
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -247,23 +246,50 @@ class SeerrDiscoverViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private var ratingJob: Job? = null
|
||||
private val ratingCache =
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build<Int, DiscoverRating>()
|
||||
|
||||
/**
|
||||
* Fetch the ratings for the focused item, cancelling any previous request that is still running
|
||||
*/
|
||||
// TODO this is not very efficient
|
||||
fun fetchRating(
|
||||
id: Int,
|
||||
type: SeerrItemType,
|
||||
) {
|
||||
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) }
|
||||
viewModelScope.launchIO {
|
||||
val cachedResult = ratingCache.getIfPresent(id)
|
||||
if (cachedResult != null) {
|
||||
return@launchIO
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -389,7 +415,7 @@ fun SeerrDiscoverPage(
|
|||
modifier = modifier,
|
||||
) {
|
||||
val details =
|
||||
remember(focusedItem) {
|
||||
remember(focusedItem, ratingMap) {
|
||||
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
|
||||
buildList {
|
||||
focusedItem.item
|
||||
|
|
@ -398,18 +424,23 @@ fun SeerrDiscoverPage(
|
|||
?.toString()
|
||||
?.let(::add)
|
||||
}.let {
|
||||
QuickDetailsData(listToDotString(it))
|
||||
val rating = focusedItem.id.let { ratingMap[it] }
|
||||
val str =
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
)
|
||||
QuickDetailsData(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ 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
|
||||
|
|
@ -73,7 +72,6 @@ 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
|
||||
|
|
@ -573,7 +571,6 @@ fun HomePageHeader(
|
|||
overview = item?.data?.overview,
|
||||
overviewTwoLines = isEpisode,
|
||||
quickDetails = item?.ui?.quickDetails,
|
||||
ratings = rememberSourceRatings(item),
|
||||
timeRemaining = item?.timeRemainingOrRuntime,
|
||||
endsAt = item?.data?.endDate,
|
||||
showLogo = showLogo,
|
||||
|
|
@ -589,7 +586,6 @@ fun HomePageHeader(
|
|||
overview: String?,
|
||||
overviewTwoLines: Boolean,
|
||||
quickDetails: QuickDetailsData?,
|
||||
ratings: SourceRatings?,
|
||||
timeRemaining: Duration?,
|
||||
endsAt: DateTime?,
|
||||
showLogo: Boolean,
|
||||
|
|
@ -615,7 +611,7 @@ fun HomePageHeader(
|
|||
if (subtitle != null) {
|
||||
EpisodeName(subtitle)
|
||||
}
|
||||
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings)
|
||||
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt)
|
||||
val overviewModifier =
|
||||
Modifier
|
||||
.padding(0.dp)
|
||||
|
|
|
|||
|
|
@ -771,7 +771,6 @@ fun navItemColor(
|
|||
when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
|
|||
|
|
@ -717,7 +717,6 @@ class PlaybackViewModel
|
|||
api.videosApi.getVideoStreamUrl(
|
||||
itemId = itemId,
|
||||
mediaSourceId = source.id,
|
||||
liveStreamId = source.liveStreamId,
|
||||
static = true,
|
||||
tag = source.eTag,
|
||||
playSessionId = response.playSessionId,
|
||||
|
|
|
|||
|
|
@ -298,9 +298,6 @@ fun PreferencesContent(
|
|||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
if (group.title == R.string.about) {
|
||||
WolfskinAbout()
|
||||
}
|
||||
}
|
||||
val groupPreferences =
|
||||
group.preferences +
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ fun SwitchColors(): SwitchColors {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
-> {
|
||||
SwitchDefaults.colors()
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ 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
|
||||
|
|
@ -89,24 +88,19 @@ fun UserList(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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()
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val firstFocusRequester = remember { FocusRequester() }
|
||||
if (users.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) { focusRequester.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(initialFocusRequester),
|
||||
.focusRestorer(firstFocusRequester)
|
||||
.focusRequester(focusRequester),
|
||||
) {
|
||||
itemsIndexed(users) { index, user ->
|
||||
UserIconCard(
|
||||
|
|
@ -114,7 +108,7 @@ fun UserList(
|
|||
isCurrentUser = user.user.id == currentUser?.id,
|
||||
onClick = { onSwitchUser.invoke(user.user) },
|
||||
onLongClick = { showDeleteDialog = user },
|
||||
modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier,
|
||||
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
|
||||
)
|
||||
}
|
||||
// Add User card - always rightmost
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ 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
|
||||
|
|
@ -56,13 +55,13 @@ fun AddSeerrServerApiKey(
|
|||
.padding(16.dp)
|
||||
.wrapContentSize(),
|
||||
) {
|
||||
var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
|
||||
var url by remember { mutableStateOf(initialUrl) }
|
||||
var apiKey by remember { mutableStateOf("") }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (url.isBlank()) {
|
||||
if (initialUrl.isBlank()) {
|
||||
focusRequester.tryRequestFocus()
|
||||
} else {
|
||||
passwordFocusRequester.tryRequestFocus()
|
||||
|
|
@ -175,7 +174,7 @@ fun AddSeerrServerUsername(
|
|||
.padding(16.dp)
|
||||
.wrapContentSize(),
|
||||
) {
|
||||
var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) }
|
||||
var url by remember { mutableStateOf(initialUrl) }
|
||||
var username by remember { mutableStateOf(username) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,10 @@ 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) },
|
||||
|
|
@ -233,10 +237,6 @@ 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) },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ 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.WOLFSKIN }
|
||||
compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE }
|
||||
|
||||
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
||||
when (appThemeColors) {
|
||||
|
|
@ -28,14 +27,13 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
|||
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
|
||||
AppThemeColors.RED -> RedThemeColors
|
||||
AppThemeColors.BROWN -> BrownThemeColors
|
||||
AppThemeColors.WOLFSKIN -> WolfskinThemeColors
|
||||
AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors
|
||||
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WholphinTheme(
|
||||
darkTheme: Boolean = true,
|
||||
appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN,
|
||||
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val themeColors = getThemeColors(appThemeColors)
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
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),
|
||||
)
|
||||
}
|
||||
|
|
@ -436,7 +436,7 @@ val requestSerializersModule =
|
|||
subclass(GetStudiosRequest::class)
|
||||
subclass(GetRecordingsRequest::class)
|
||||
subclass(GetLiveTvChannelsRequest::class)
|
||||
subclass(GetArtistsRequest::class)
|
||||
subclass(GetAlbumArtistsRequest::class)
|
||||
subclass(GetArtistsHandler::class)
|
||||
subclass(GetAlbumArtistsHandler::class)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,6 @@ enum AppThemeColors {
|
|||
OLED_BLACK = 5;
|
||||
RED = 6;
|
||||
BROWN = 7;
|
||||
WOLFSKIN = 8;
|
||||
}
|
||||
|
||||
enum EdgeStyle{
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:src="@mipmap/ic_banner_foreground"
|
||||
android:gravity="fill" />
|
||||
<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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -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 Wolfskin</string>
|
||||
<string name="display_preset_compact">Wolfskin kompaktní</string>
|
||||
<string name="display_preset_default">Výchozí předvolba Wholphin</string>
|
||||
<string name="display_preset_compact">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin-standard</string>
|
||||
<string name="display_preset_compact">Wolfskin kompakt</string>
|
||||
<string name="display_preset_default">Wholphin-standard</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin er opdateret til %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin Standard</string>
|
||||
<string name="display_preset_compact">Wolfskin Kompakt</string>
|
||||
<string name="display_preset_default">Wholphin Standard</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin aktualisiert auf %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin se ha actualizado a la versión %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskini vaikeseadistus</string>
|
||||
<string name="display_preset_compact">Wolfskini kompaktne vaade</string>
|
||||
<string name="display_preset_default">Wholphini vaikeseadistus</string>
|
||||
<string name="display_preset_compact">Wholphini 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">Wolfskin on uuendatud versioonini %s</string>
|
||||
<string name="updated_toast">Wholphin 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,5 +758,4 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin Par défaut</string>
|
||||
<string name="display_preset_compact">Wolfskin Compact</string>
|
||||
<string name="display_preset_default">Wholphin Par défaut</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin a été mis à jour vers la version %s</string>
|
||||
<string name="updated_toast">Wholphin 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,9 +777,4 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin zadani</string>
|
||||
<string name="display_preset_compact">Wolfskin zbijeni</string>
|
||||
<string name="display_preset_default">Wholphin zadani</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin ažuriran na %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin Bawaan</string>
|
||||
<string name="display_preset_compact">Wolfskin Ringkas</string>
|
||||
<string name="display_preset_default">Wholphin Bawaan</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin diperbarui ke %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin predefinito</string>
|
||||
<string name="display_preset_compact">Wolfskin Compatto</string>
|
||||
<string name="display_preset_default">Wholphin predefinito</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin aggiornato a %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">ברירת המחדל של Wolfskin</string>
|
||||
<string name="display_preset_compact">Wolfskin קומפקטי</string>
|
||||
<string name="display_preset_default">ברירת המחדל של Wholphin</string>
|
||||
<string name="display_preset_compact">Wholphin קומפקטי</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">Wolfskin עודכן ל־%s</string>
|
||||
<string name="updated_toast">Wholphin עודכן ל־%s</string>
|
||||
<string name="remove_continue_watching">מחק מהמשך צפייה</string>
|
||||
<string name="remove_next_up">מחק סדרה מ־\"הבא בתור\"</string>
|
||||
<string name="view_removed_next_up">הצג סדרות שנמחקו מ־\"הבא בתור\"</string>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
@ -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">Wolfskin Standard</string>
|
||||
<string name="display_preset_compact">Wolfskin Kompakt</string>
|
||||
<string name="display_preset_default">Wholphin Standard</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin oppdatert til %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin standaard</string>
|
||||
<string name="display_preset_compact">Wolfskin compact</string>
|
||||
<string name="display_preset_default">Wholphin standaard</string>
|
||||
<string name="display_preset_compact">Wholphin 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 & download ondertiteling]]></string>
|
||||
<string name="updated_toast">Wolfskin is geüpdatet naar %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin-standard</string>
|
||||
<string name="display_preset_compact">Wolfskin-kompakt</string>
|
||||
<string name="display_preset_default">Wholphin-standard</string>
|
||||
<string name="display_preset_compact">Wholphin-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>
|
||||
|
|
|
|||
|
|
@ -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 Wolfskin</string>
|
||||
<string name="display_preset_compact">Kompaktowy Wolfskin</string>
|
||||
<string name="display_preset_default">Domyślny Wholphin</string>
|
||||
<string name="display_preset_compact">Kompaktowy Wholphin</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">Wolfskin zaktualizowany do %s</string>
|
||||
<string name="updated_toast">Wholphin 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,9 +800,4 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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 Wolfskin</string>
|
||||
<string name="display_preset_compact">Wolfskin Compacto</string>
|
||||
<string name="display_preset_default">Padrão do Wholphin</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin atualizado para %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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 Wolfskin</string>
|
||||
<string name="display_preset_compact">Wolfskin Compacto</string>
|
||||
<string name="display_preset_default">Padrão do Wholphin</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin foi atualizado para %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin по умолчанию</string>
|
||||
<string name="display_preset_compact">Wolfskin компактный</string>
|
||||
<string name="display_preset_default">Wholphin по умолчанию</string>
|
||||
<string name="display_preset_compact">Wholphin компактный</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">Wolfskin обновлён до версии %s</string>
|
||||
<string name="updated_toast">Wholphin обновлён до версии %s</string>
|
||||
<string name="remove_continue_watching">Убрать из «Продолжить просмотр»</string>
|
||||
<string name="remove_next_up">Убрать сериал из «Далее»</string>
|
||||
<string name="view_removed_next_up">Показать сериалы, убранные из «Далее»</string>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin predvolené</string>
|
||||
<string name="display_preset_compact">Wolfskin kompaktné</string>
|
||||
<string name="display_preset_default">Wholphin predvolené</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin bol aktualizovaný na verziu %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin posodobljen na %s</string>
|
||||
<string name="updated_toast">Wholphin 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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin Kompakt</string>
|
||||
<string name="display_preset_default">Wolfskin Standard</string>
|
||||
<string name="display_preset_compact">Wholphin Kompakt</string>
|
||||
<string name="display_preset_default">Wholphin 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">Wolfskin har uppdaterats till %s</string>
|
||||
<string name="updated_toast">Wholphin har uppdaterats till %s</string>
|
||||
<string name="last_played">Senast visat</string>
|
||||
<string name="root_folder">Huvudkatalog</string>
|
||||
<string name="popularity">Populäritet</string>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin ค่าเริ่มต้น</string>
|
||||
<string name="display_preset_compact">Wolfskin คอมแพค</string>
|
||||
<string name="display_preset_default">Wholphin ค่าเริ่มต้น</string>
|
||||
<string name="display_preset_compact">Wholphin คอมแพค</string>
|
||||
<string name="display_preset_series_thumb">ภาพขนาดย่อของซีรี่ส์</string>
|
||||
<string name="display_preset_episode_thumbnails">ภาพขนาดย่อของตอน</string>
|
||||
<string name="volume_lowest">เบาสุด</string>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin Varsayılan</string>
|
||||
<string name="display_preset_compact">Wolfskin Kompakt</string>
|
||||
<string name="display_preset_default">Wholphin Varsayılan</string>
|
||||
<string name="display_preset_compact">Wholphin 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">Wolfskin %s sürümüne güncellendi</string>
|
||||
<string name="updated_toast">Wholphin %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>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin за замовчуванням</string>
|
||||
<string name="display_preset_default">Wholphin за замовчуванням</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">Wolfskin оновлено до %s</string>
|
||||
<string name="updated_toast">Wholphin оновлено до %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">Wolfskin компактний</string>
|
||||
<string name="display_preset_compact">Wholphin компактний</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin 默认</string>
|
||||
<string name="display_preset_compact">Wolfskin 紧凑</string>
|
||||
<string name="display_preset_default">Wholphin 默认</string>
|
||||
<string name="display_preset_compact">Wholphin 紧凑</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">Wolfskin 已更新至 %s</string>
|
||||
<string name="updated_toast">Wholphin 已更新至 %s</string>
|
||||
<string name="view_more">查看更多</string>
|
||||
<string name="discover_tv">发现电视节目</string>
|
||||
<string name="discover_movies">发现电影</string>
|
||||
|
|
|
|||
|
|
@ -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">Wolfskin 預設樣式</string>
|
||||
<string name="display_preset_compact">Wolfskin 緊湊樣式</string>
|
||||
<string name="display_preset_default">Wholphin 預設樣式</string>
|
||||
<string name="display_preset_compact">Wholphin 緊湊樣式</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">Wolfskin 已更新至 %s</string>
|
||||
<string name="updated_toast">Wholphin 已更新至 %s</string>
|
||||
<string name="view_more">查看更多</string>
|
||||
<string name="discover_tv">探索電視劇</string>
|
||||
<string name="discover_movies">探索電影</string>
|
||||
|
|
|
|||
4
app/src/main/res/values/ic_banner_background.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_banner_background">#000B25</color>
|
||||
</resources>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#07070a</color>
|
||||
<color name="ic_launcher_background">#101010</color>
|
||||
</resources>
|
||||
|
|
|
|||