mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-08 15:08:40 +01:00
Compare commits
1 Commits
fix/androi
...
I10n_crowd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a76f3a3df9 |
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
@@ -231,48 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||||
TextInput fallback on Android TV (the native module is Apple-only). */}
|
module). It renders the native search bar + grid keyboard and
|
||||||
{Platform.OS === "ios" ? (
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
<View
|
our own results grid renders below. */}
|
||||||
style={{
|
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||||
marginBottom: 24,
|
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||||
height: SEARCH_AREA_HEIGHT,
|
margins squeeze the bar's width and clip that trailing hint, so let
|
||||||
}}
|
the native view span the full width and own its own insets. */}
|
||||||
>
|
<View
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers
|
style={{
|
||||||
itself and renders a trailing "Hold to Dictate" hint. */}
|
marginBottom: 24,
|
||||||
<TvSearchView
|
height: SEARCH_AREA_HEIGHT,
|
||||||
style={{ width: "100%", height: "100%" }}
|
}}
|
||||||
placeholder={t("search.search")}
|
>
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
<TvSearchView
|
||||||
/>
|
style={{ width: "100%", height: "100%" }}
|
||||||
</View>
|
placeholder={t("search.search")}
|
||||||
) : (
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||||
<View
|
/>
|
||||||
style={{
|
</View>
|
||||||
marginHorizontal: HORIZONTAL_PADDING,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
style={{
|
|
||||||
height: 56,
|
|
||||||
width: "100%",
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
fontSize: 28,
|
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
placeholderTextColor='rgba(255,255,255,0.4)'
|
|
||||||
onChangeText={setSearch}
|
|
||||||
defaultValue=''
|
|
||||||
autoFocus={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application>
|
<application>
|
||||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
<receiver
|
||||||
|
android:name=".TvRecommendationsReceiver"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -61,61 +61,31 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
fun clear(context: Context): Boolean {
|
fun clear(context: Context): Boolean {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
|
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
|
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
if (programIds != null) {
|
||||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
|
||||||
if (allProgramIds != null) {
|
|
||||||
var deletedPrograms = 0
|
var deletedPrograms = 0
|
||||||
val channelKeys = allProgramIds.keys()
|
val keys = programIds.keys()
|
||||||
while (channelKeys.hasNext()) {
|
while (keys.hasNext()) {
|
||||||
val channelIdStr = channelKeys.next()
|
val key = keys.next()
|
||||||
val programIdsJson = allProgramIds.optString(channelIdStr)
|
val programId = programIds.optLong(key, -1L)
|
||||||
if (programIdsJson.isBlank()) continue
|
if (programId > 0L) {
|
||||||
|
contentResolver.delete(
|
||||||
try {
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
val programIds = JSONObject(programIdsJson)
|
null,
|
||||||
val keys = programIds.keys()
|
null
|
||||||
while (keys.hasNext()) {
|
)
|
||||||
val providerId = keys.next()
|
deletedPrograms += 1
|
||||||
val programId = programIds.optLong(providerId, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(contentResolver, programId)
|
|
||||||
deletedPrograms += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the channel
|
|
||||||
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
|
||||||
if (channelId > 0L) {
|
|
||||||
try {
|
|
||||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove per-channel pref
|
|
||||||
prefs.edit().remove("programIds_$channelIdStr").apply()
|
|
||||||
}
|
}
|
||||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also handle legacy format (flat { providerId: programId }) for migration
|
if (channelId > 0L) {
|
||||||
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||||
if (legacyProgramIds != null) {
|
Log.d(TAG, "clear(): notified channel $channelId")
|
||||||
val keys = legacyProgramIds.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val programId = legacyProgramIds.optLong(key, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(contentResolver, programId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -126,262 +96,126 @@ internal object TvRecommendationsPublisher {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a single preview program from the TvProvider.
|
|
||||||
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
|
||||||
*/
|
|
||||||
fun deletePreviewProgram(context: Context, programId: Long) {
|
|
||||||
try {
|
|
||||||
context.contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
|
||||||
|
|
||||||
// Also remove from stored programIds prefs
|
|
||||||
removeProgramFromPrefs(context, programId)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
|
||||||
try {
|
|
||||||
contentResolver.delete(
|
|
||||||
TvContractCompat.buildPreviewProgramUri(programId),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
|
||||||
val prefs = preferences(context)
|
|
||||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
|
||||||
try {
|
|
||||||
val programIds = JSONObject(programIdsJson)
|
|
||||||
val keys = programIds.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
if (programIds.optLong(key, -1L) == programId) {
|
|
||||||
programIds.remove(key)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||||
if (sections.length() == 0) {
|
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
||||||
Log.w(TAG, "synchronize(): no sections in payload")
|
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||||
return false
|
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
||||||
}
|
|
||||||
|
|
||||||
val prefs = preferences(context)
|
|
||||||
val allNextProgramIds = JSONObject()
|
|
||||||
var totalActive = 0
|
|
||||||
var totalDeleted = 0
|
|
||||||
|
|
||||||
for (sectionIndex in 0 until sections.length()) {
|
|
||||||
val section = sections.optJSONObject(sectionIndex) ?: continue
|
|
||||||
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
|
||||||
val items = section.optJSONArray("items") ?: JSONArray()
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
|
||||||
if (channelId <= 0L) {
|
|
||||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per Android docs: check channel.isBrowsable() and request if needed.
|
|
||||||
if (!isChannelBrowsable(context, channelId)) {
|
|
||||||
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val prefKey = "programIds_$channelId"
|
|
||||||
val previousProgramIds = prefs.getString(prefKey, null)
|
|
||||||
?.let(::JSONObject)
|
|
||||||
?: JSONObject()
|
|
||||||
val nextProgramIds = JSONObject()
|
|
||||||
val activeProviderIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
for (index in 0 until items.length()) {
|
|
||||||
val item = items.optJSONObject(index) ?: continue
|
|
||||||
val providerId = item.optString("id")
|
|
||||||
if (providerId.isBlank()) continue
|
|
||||||
|
|
||||||
val programId = upsertPreviewProgram(
|
|
||||||
context = context,
|
|
||||||
channelId = channelId,
|
|
||||||
item = item,
|
|
||||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
|
||||||
weight = index
|
|
||||||
)
|
|
||||||
|
|
||||||
if (programId > 0L) {
|
|
||||||
activeProviderIds += providerId
|
|
||||||
nextProgramIds.put(providerId, programId)
|
|
||||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var deletedPrograms = 0
|
|
||||||
val previousKeys = previousProgramIds.keys()
|
|
||||||
while (previousKeys.hasNext()) {
|
|
||||||
val providerId = previousKeys.next()
|
|
||||||
if (activeProviderIds.contains(providerId)) continue
|
|
||||||
|
|
||||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
|
||||||
if (programId > 0L) {
|
|
||||||
deletePreviewProgram(context, programId)
|
|
||||||
deletedPrograms += 1
|
|
||||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
|
||||||
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
|
||||||
totalActive += activeProviderIds.size
|
|
||||||
totalDeleted += deletedPrograms
|
|
||||||
|
|
||||||
logProviderState(context, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store all channel program IDs for clear() to use
|
|
||||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||||
|
if (channelId <= 0L) {
|
||||||
|
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
||||||
|
|
||||||
|
val previousProgramIds = preferences(context)
|
||||||
|
.getString(KEY_PROGRAM_IDS, null)
|
||||||
|
?.let(::JSONObject)
|
||||||
|
?: JSONObject()
|
||||||
|
val nextProgramIds = JSONObject()
|
||||||
|
val activeProviderIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
for (index in 0 until items.length()) {
|
||||||
|
val item = items.optJSONObject(index) ?: continue
|
||||||
|
val providerId = item.optString("id")
|
||||||
|
if (providerId.isBlank()) continue
|
||||||
|
|
||||||
|
val programId = upsertPreviewProgram(
|
||||||
|
context = context,
|
||||||
|
channelId = channelId,
|
||||||
|
item = item,
|
||||||
|
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||||
|
weight = index
|
||||||
|
)
|
||||||
|
|
||||||
|
if (programId > 0L) {
|
||||||
|
activeProviderIds += providerId
|
||||||
|
nextProgramIds.put(providerId, programId)
|
||||||
|
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedPrograms = 0
|
||||||
|
val previousKeys = previousProgramIds.keys()
|
||||||
|
while (previousKeys.hasNext()) {
|
||||||
|
val providerId = previousKeys.next()
|
||||||
|
if (activeProviderIds.contains(providerId)) continue
|
||||||
|
|
||||||
|
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||||
|
if (programId > 0L) {
|
||||||
|
context.contentResolver.delete(
|
||||||
|
TvContractCompat.buildPreviewProgramUri(programId),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
deletedPrograms += 1
|
||||||
|
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences(context)
|
||||||
|
.edit()
|
||||||
|
.putLong(KEY_CHANNEL_ID, channelId)
|
||||||
|
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
logProviderState(context, channelId)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Query provider to check if a channel is browsable.
|
|
||||||
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
|
||||||
*/
|
|
||||||
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
context.contentResolver.query(
|
|
||||||
TvContractCompat.buildChannelUri(channelId),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
|
||||||
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ?: false
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
|
||||||
true // Assume browsable if we can't check, to avoid blocking updates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query provider to verify a channel actually exists.
|
|
||||||
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
|
||||||
* we must first check whether the channel was deleted by the system
|
|
||||||
* or if the update simply failed for another reason.
|
|
||||||
*/
|
|
||||||
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
context.contentResolver.query(
|
|
||||||
TvContractCompat.buildChannelUri(channelId),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)?.use { cursor ->
|
|
||||||
cursor.moveToFirst()
|
|
||||||
} ?: false
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||||
val prefs = preferences(context)
|
val prefs = preferences(context)
|
||||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (existingChannelId > 0L) {
|
if (existingChannelId > 0L) {
|
||||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
val updated = Channel.Builder()
|
||||||
val exists = channelExistsInProvider(context, existingChannelId)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(displayName)
|
||||||
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (exists) {
|
val updatedRows = contentResolver.update(
|
||||||
// Channel exists — update it in place, never recreate
|
TvContractCompat.buildChannelUri(existingChannelId),
|
||||||
val updated = Channel.Builder()
|
updated.toContentValues(),
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
null,
|
||||||
.setDisplayName(displayName)
|
null
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
)
|
||||||
.build()
|
|
||||||
|
|
||||||
try {
|
if (updatedRows > 0) {
|
||||||
val updatedRows = contentResolver.update(
|
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||||
TvContractCompat.buildChannelUri(existingChannelId),
|
storeChannelLogo(context, existingChannelId)
|
||||||
updated.toContentValues(),
|
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||||
null,
|
return existingChannelId
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
|
||||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
|
||||||
storeChannelLogo(context, existingChannelId)
|
|
||||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
|
||||||
return existingChannelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
|
||||||
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
|
||||||
return existingChannelId
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
|
||||||
return existingChannelId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel truly doesn't exist in provider — recreate
|
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
||||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
|
||||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new channel
|
|
||||||
val channel = Channel.Builder()
|
val channel = Channel.Builder()
|
||||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
.setDisplayName(displayName)
|
.setDisplayName(displayName)
|
||||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val channelUri = try {
|
val channelUri = contentResolver.insert(
|
||||||
contentResolver.insert(
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
TvContractCompat.Channels.CONTENT_URI,
|
channel.toContentValues()
|
||||||
channel.toContentValues()
|
) ?: return -1L
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
|
||||||
null
|
|
||||||
} ?: return -1L
|
|
||||||
|
|
||||||
val channelId = ContentUris.parseId(channelUri)
|
val channelId = ContentUris.parseId(channelUri)
|
||||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||||
@@ -415,62 +249,42 @@ internal object TvRecommendationsPublisher {
|
|||||||
builder.setDescription(it)
|
builder.setDescription(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per Android docs: use unique URIs for all images to avoid stale cache
|
|
||||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||||
val uniqueImageUrl = appendCacheBuster(it)
|
val imageUri = Uri.parse(it)
|
||||||
val imageUri = Uri.parse(uniqueImageUrl)
|
|
||||||
builder.setPosterArtUri(imageUri)
|
builder.setPosterArtUri(imageUri)
|
||||||
builder.setThumbnailUri(imageUri)
|
builder.setThumbnailUri(imageUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val contentValues = builder.build().toContentValues()
|
val contentValues = builder.build().toContentValues()
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
if (previousProgramId > 0L) {
|
if (previousProgramId > 0L) {
|
||||||
try {
|
val updatedRows = contentResolver.update(
|
||||||
val updatedRows = contentResolver.update(
|
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
contentValues,
|
||||||
contentValues,
|
null,
|
||||||
null,
|
null
|
||||||
null
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedRows > 0) {
|
if (updatedRows > 0) {
|
||||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||||
return previousProgramId
|
return previousProgramId
|
||||||
}
|
|
||||||
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||||
}
|
}
|
||||||
|
|
||||||
val insertedUri = try {
|
val insertedUri = contentResolver.insert(
|
||||||
contentResolver.insert(
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
contentValues
|
||||||
contentValues
|
) ?: return -1L
|
||||||
)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
|
||||||
null
|
|
||||||
} ?: return -1L
|
|
||||||
|
|
||||||
val programId = ContentUris.parseId(insertedUri)
|
val programId = ContentUris.parseId(insertedUri)
|
||||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||||
return programId
|
return programId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a cache-busting parameter to ensure unique URIs when images change.
|
|
||||||
* Per Android docs: "Use unique Uris for all images... the old image will
|
|
||||||
* continue to appear if you don't change the Uri."
|
|
||||||
*/
|
|
||||||
private fun appendCacheBuster(imageUrl: String): String {
|
|
||||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
|
||||||
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = Uri.parse(deepLink)
|
data = Uri.parse(deepLink)
|
||||||
@@ -492,17 +306,13 @@ internal object TvRecommendationsPublisher {
|
|||||||
|
|
||||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||||
val bitmap = applicationIconBitmap(context) ?: return
|
val bitmap = applicationIconBitmap(context) ?: return
|
||||||
try {
|
val outputStream = context.contentResolver.openOutputStream(
|
||||||
val outputStream = context.contentResolver.openOutputStream(
|
TvContractCompat.buildChannelLogoUri(channelId)
|
||||||
TvContractCompat.buildChannelLogoUri(channelId)
|
) ?: return
|
||||||
) ?: return
|
|
||||||
|
|
||||||
outputStream.use { stream ->
|
outputStream.use { stream ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
}
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,14 +341,9 @@ internal object TvRecommendationsPublisher {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChannelId(context: Context): Long {
|
|
||||||
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preferences(context: Context): SharedPreferences {
|
private fun preferences(context: Context): SharedPreferences {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logProviderState(context: Context, channelId: Long) {
|
private fun logProviderState(context: Context, channelId: Long) {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
|
|
||||||
@@ -567,8 +372,8 @@ internal object TvRecommendationsPublisher {
|
|||||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||||
}
|
}
|
||||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||||
} catch (error: SecurityException) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import { requireNativeView } from "expo";
|
import { requireNativeView } from "expo";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { View } from "react-native";
|
import type { View } from "react-native";
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||||
|
|
||||||
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
|
||||||
// On Android the component is never rendered, but we must avoid calling
|
|
||||||
// `requireNativeView` at module-scope because it would crash on import.
|
|
||||||
const NativeView: React.ComponentType<
|
const NativeView: React.ComponentType<
|
||||||
TvSearchViewProps & React.RefAttributes<View>
|
TvSearchViewProps & React.RefAttributes<View>
|
||||||
> =
|
> = requireNativeView("TvSearchModule");
|
||||||
Platform.OS === "ios"
|
|
||||||
? requireNativeView("TvSearchModule")
|
|
||||||
: ((() => null) as any);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards its ref to the underlying native view so it can be used as a
|
* Forwards its ref to the underlying native view so it can be used as a
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"error_title": "Fehler",
|
"error_title": "Fehler",
|
||||||
"login_title": "Anmelden",
|
"login_title": "Anmelden",
|
||||||
"login_to_title": "Anmelden bei",
|
"login_to_title": "Anmelden bei",
|
||||||
"select_user": "Select a user to log in",
|
"select_user": "Benutzer zum Anmelden auswählen",
|
||||||
"add_user_to_login": "Add a user to log in",
|
"add_user_to_login": "Zum Anmelden einen Benutzer hinzufügen",
|
||||||
"add_user": "Add User",
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Benutzername",
|
"username_placeholder": "Benutzername",
|
||||||
"password_placeholder": "Passwort",
|
"password_placeholder": "Passwort",
|
||||||
@@ -47,9 +47,9 @@
|
|||||||
"add_account": "Konto hinzufügen",
|
"add_account": "Konto hinzufügen",
|
||||||
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
"remove_account_description": "Hiermit werden die gespeicherten Zugangsdaten für {{username}} entfernt.",
|
||||||
"remove_server": "Remove Server",
|
"remove_server": "Remove Server",
|
||||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
"remove_server_description": "Dies wird {{server}} und alle gespeicherten Konten aus Ihrer Liste entfernen.",
|
||||||
"select_your_server": "Select Your Server",
|
"select_your_server": "Select Your Server",
|
||||||
"add_server_to_get_started": "Add a server to get started",
|
"add_server_to_get_started": "Füge einen Server hinzu, um loszulegen",
|
||||||
"add_server": "Add Server",
|
"add_server": "Add Server",
|
||||||
"change_server": "Change Server"
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"oops": "Ups!",
|
"oops": "Ups!",
|
||||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||||
"continue_watching": "Weiterschauen",
|
"continue_watching": "Weiterschauen",
|
||||||
"continue": "Continue",
|
"continue": "Weiter",
|
||||||
"next_up": "Als nächstes",
|
"next_up": "Als nächstes",
|
||||||
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
"continue_and_next_up": "\"Weiterschauen\" und \"Als Nächstes\"",
|
||||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||||
@@ -121,9 +121,9 @@
|
|||||||
"log_out_button": "Abmelden",
|
"log_out_button": "Abmelden",
|
||||||
"switch_user": {
|
"switch_user": {
|
||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Benutzerkonto",
|
||||||
"switch_user": "Switch User on This Server",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "current"
|
"current": "aktuell"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Kategorien"
|
"title": "Kategorien"
|
||||||
@@ -143,9 +143,9 @@
|
|||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
"theme_music": "Theme Music",
|
"theme_music": "Theme Music",
|
||||||
"display_size": "Display Size",
|
"display_size": "Display Size",
|
||||||
"display_size_small": "Small",
|
"display_size_small": "Klein",
|
||||||
"display_size_default": "Default",
|
"display_size_default": "Standard",
|
||||||
"display_size_large": "Large",
|
"display_size_large": "Groß",
|
||||||
"display_size_extra_large": "Extra Large"
|
"display_size_extra_large": "Extra Large"
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
"title": "Buffer Settings",
|
"title": "Buffer Settings",
|
||||||
"cache_mode": "Cache Mode",
|
"cache_mode": "Cache Mode",
|
||||||
"cache_auto": "Auto",
|
"cache_auto": "Auto",
|
||||||
"cache_yes": "Enabled",
|
"cache_yes": "Aktiviert",
|
||||||
"cache_no": "Disabled",
|
"cache_no": "Deaktiviert",
|
||||||
"buffer_duration": "Buffer Duration",
|
"buffer_duration": "Buffer Duration",
|
||||||
"max_cache_size": "Max Cache Size",
|
"max_cache_size": "Max Cache Size",
|
||||||
"max_backward_cache": "Max Backward Cache"
|
"max_backward_cache": "Max Backward Cache"
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
"vo_driver": {
|
"vo_driver": {
|
||||||
"title": "Video Output",
|
"title": "Video Output",
|
||||||
"vo_mode": "VO Driver",
|
"vo_mode": "VO Driver",
|
||||||
"gpu_next": "gpu-next (Recommended)",
|
"gpu_next": "gpu-next (empfohlen)",
|
||||||
"gpu": "gpu"
|
"gpu": "gpu"
|
||||||
},
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
@@ -299,20 +299,20 @@
|
|||||||
"hardware_decode": "Hardware Decoding",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
|
"hardware_decode_description": "Hardwarebeschleunigung für Video Decoding verwenden. Deaktivieren wenn Wiedergabeprobleme auftreten.",
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
"opensubtitles_title": "OpenSubtitles",
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
"opensubtitles_hint": "Geben Sie Ihren OpenSubtitles API-Schlüssel ein, um die Client-seitige Untertitelsuche als Fallback zu aktivieren, wenn Ihr Jellyfin-Server keinen Untertitelanbieter konfiguriert hat.",
|
||||||
"opensubtitles_api_key": "API Key",
|
"opensubtitles_api_key": "API Key",
|
||||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
"opensubtitles_api_key_placeholder": "API-Schüssel eingeben ...",
|
||||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
"opensubtitles_get_key": "Holen Sie sich Ihren kostenlosen API-Schlüssel unter opensubtitles.com/de/consumers",
|
||||||
"mpv_subtitle_scale": "Subtitle Scale",
|
"mpv_subtitle_scale": "Subtitle Scale",
|
||||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||||
"mpv_subtitle_align_x": "Horizontal Align",
|
"mpv_subtitle_align_x": "Horizontal Align",
|
||||||
"mpv_subtitle_align_y": "Vertical Align",
|
"mpv_subtitle_align_y": "Vertical Align",
|
||||||
"align": {
|
"align": {
|
||||||
"left": "Left",
|
"left": "Links",
|
||||||
"center": "Center",
|
"center": "Mittig",
|
||||||
"right": "Right",
|
"right": "Rechts",
|
||||||
"top": "Top",
|
"top": "Oben",
|
||||||
"bottom": "Bottom"
|
"bottom": "Unten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
@@ -466,10 +466,10 @@
|
|||||||
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
"downloaded_songs_deleted": "Heruntergeladene Titel gelöscht",
|
||||||
"clear_all_cache": "Clear All Cache",
|
"clear_all_cache": "Clear All Cache",
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
"clear_all_cache_confirm": "Clear All Cache?",
|
||||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
"clear_all_cache_confirm_desc": "Sind Sie sicher, dass Sie alle zwischengespeicherten Daten löschen möchten? Dadurch werden alle zwischengespeicherten Bilder, Musikdateien, Untertitel und Abfrage-Caches gelöscht. Ihre Einstellungen und Login-Sitzung werden beibehalten.",
|
||||||
"clear_all_cache_success": "Cache Cleared",
|
"clear_all_cache_success": "Cache Cleared",
|
||||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
"clear_all_cache_success_desc": "Der Cache wurde erfolgreich geleert.",
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
"clear_all_cache_error_desc": "Beim Löschen des Caches ist ein Fehler aufgetreten."
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "Einführung",
|
"title": "Einführung",
|
||||||
@@ -495,18 +495,18 @@
|
|||||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Sicherheit",
|
||||||
"inactivity_timeout": {
|
"inactivity_timeout": {
|
||||||
"title": "Inactivity Timeout",
|
"title": "Inactivity Timeout",
|
||||||
"description": "Auto logout after inactivity",
|
"description": "Automatische Abmeldung nach Inaktivität",
|
||||||
"disabled": "Disabled",
|
"disabled": "Deaktiviert",
|
||||||
"1_minute": "1 minute",
|
"1_minute": "1 Minute",
|
||||||
"5_minutes": "5 minutes",
|
"5_minutes": "5 Minuten",
|
||||||
"15_minutes": "15 minutes",
|
"15_minutes": "15 Minuten",
|
||||||
"30_minutes": "30 minutes",
|
"30_minutes": "30 Minuten",
|
||||||
"1_hour": "1 hour",
|
"1_hour": "1 Stunde",
|
||||||
"4_hours": "4 hours",
|
"4_hours": "4 Stunden",
|
||||||
"24_hours": "24 hours"
|
"24_hours": "24 Stunden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -591,8 +591,8 @@
|
|||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"continue": "Fortsetzen",
|
"continue": "Fortsetzen",
|
||||||
"verifying": "Verifiziere...",
|
"verifying": "Verifiziere...",
|
||||||
"login": "Login",
|
"login": "Anmelden",
|
||||||
"refresh": "Refresh"
|
"refresh": "Aktualisieren"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Suchen...",
|
"search": "Suchen...",
|
||||||
@@ -641,7 +641,7 @@
|
|||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"series": "Serien",
|
"series": "Serien",
|
||||||
"boxsets": "Boxsets",
|
"boxsets": "Boxsets",
|
||||||
"playlists": "Playlists",
|
"playlists": "Wiedergabelisten",
|
||||||
"items": "Elemente"
|
"items": "Elemente"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -653,7 +653,7 @@
|
|||||||
"cover": "Cover",
|
"cover": "Cover",
|
||||||
"show_titles": "Titel anzeigen",
|
"show_titles": "Titel anzeigen",
|
||||||
"show_stats": "Statistiken anzeigen",
|
"show_stats": "Statistiken anzeigen",
|
||||||
"options_title": "Options"
|
"options_title": "Optionen"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
@@ -662,10 +662,10 @@
|
|||||||
"filter_by": "Filtern nach",
|
"filter_by": "Filtern nach",
|
||||||
"sort_order": "Sortierreihenfolge",
|
"sort_order": "Sortierreihenfolge",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"all": "All",
|
"all": "Alle",
|
||||||
"reset": "Reset",
|
"reset": "Zurücksetzen",
|
||||||
"asc": "Ascending",
|
"asc": "Aufsteigend",
|
||||||
"desc": "Descending"
|
"desc": "Absteigend"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -702,34 +702,34 @@
|
|||||||
"downloaded_file_yes": "Ja",
|
"downloaded_file_yes": "Ja",
|
||||||
"downloaded_file_no": "Nein",
|
"downloaded_file_no": "Nein",
|
||||||
"downloaded_file_cancel": "Abbrechen",
|
"downloaded_file_cancel": "Abbrechen",
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
"swipe_down_settings": "Für Einstellungen nach unten wischen",
|
||||||
"ends_at": "Endet um {{time}}",
|
"ends_at": "Endet um {{time}}",
|
||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracks",
|
"subtitle_tracks": "Titel",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
"download": "Download",
|
"download": "Herunterladen",
|
||||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
"subtitle_download_hint": "Heruntergeladene Untertitel werden in Ihrer Bibliothek gespeichert",
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
"using_jellyfin_server": "Using Jellyfin Server",
|
||||||
"language": "Language",
|
"language": "Sprache",
|
||||||
"results": "Results",
|
"results": "Ergebnisse",
|
||||||
"searching": "Searching...",
|
"searching": "Suche ...",
|
||||||
"search_failed": "Search failed",
|
"search_failed": "Suche fehlgeschlagen",
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||||
"no_subtitles_found": "No subtitles found",
|
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
"add_opensubtitles_key_hint": "OpenSubtitles API-Schlüssel in den Einstellungen für Client-seitigen Fallback hinzufügen",
|
||||||
"settings": "Settings",
|
"settings": "Einstellungen",
|
||||||
"skip_intro": "Skip Intro",
|
"skip_intro": "Skip Intro",
|
||||||
"skip_credits": "Skip Credits",
|
"skip_credits": "Skip Credits",
|
||||||
"stopPlayback": "Stop Playback",
|
"stopPlayback": "Stop Playback",
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
"stopPlayingTitle": "Wiedergabe von \"{{title}}\" beenden?",
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Bist du sicher, dass du die Wiedergabe beenden möchtest?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Heruntergeladen"
|
||||||
},
|
},
|
||||||
"chapters": {
|
"chapters": {
|
||||||
"title": "Chapters",
|
"title": "Kapitel",
|
||||||
"chapter_number": "Chapter {{number}}",
|
"chapter_number": "Kapitel {{number}}",
|
||||||
"open": "Open chapters",
|
"open": "Kapitel öffnen",
|
||||||
"close": "Close chapters"
|
"close": "Kapitel schließen"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als Nächstes",
|
||||||
@@ -754,20 +754,20 @@
|
|||||||
"quality": "Qualität",
|
"quality": "Qualität",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"label": "Subtitle",
|
"label": "Untertitel",
|
||||||
"none": "None",
|
"none": "Keine",
|
||||||
"tracks": "Tracks"
|
"tracks": "Titel"
|
||||||
},
|
},
|
||||||
"show_more": "Mehr anzeigen",
|
"show_more": "Mehr anzeigen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
"left": "left",
|
"left": "übrig",
|
||||||
"more_info": "More Info",
|
"more_info": "More Info",
|
||||||
"director": "Director",
|
"director": "Regisseur*in",
|
||||||
"cast": "Cast",
|
"cast": "Besetzung",
|
||||||
"technical_details": "Technical Details",
|
"technical_details": "Technical Details",
|
||||||
"appeared_in": "Erschien in",
|
"appeared_in": "Erschien in",
|
||||||
"movies": "Movies",
|
"movies": "Filme",
|
||||||
"shows": "Shows",
|
"shows": "Serien",
|
||||||
"could_not_load_item": "Konnte Element nicht laden",
|
"could_not_load_item": "Konnte Element nicht laden",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -782,9 +782,9 @@
|
|||||||
"mark_played": "Mark as Watched",
|
"mark_played": "Mark as Watched",
|
||||||
"mark_unplayed": "Mark as Unwatched",
|
"mark_unplayed": "Mark as Unwatched",
|
||||||
"resume_playback": "Resume Playback",
|
"resume_playback": "Resume Playback",
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
"resume_playback_description": "Möchtest du dort fortfahren, wo du aufgehört hast oder von Anfang anfangen?",
|
||||||
"play_from_start": "Play from Start",
|
"play_from_start": "Play from Start",
|
||||||
"continue_from": "Continue from {{time}}"
|
"continue_from": "Weiter ab {{time}}"
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Nächste",
|
"next": "Nächste",
|
||||||
@@ -796,16 +796,16 @@
|
|||||||
"sports": "Sport",
|
"sports": "Sport",
|
||||||
"for_kids": "Für Kinder",
|
"for_kids": "Für Kinder",
|
||||||
"news": "Nachrichten",
|
"news": "Nachrichten",
|
||||||
"page_of": "Page {{current}} of {{total}}",
|
"page_of": "Seite {{current}} von {{total}}",
|
||||||
"no_programs": "No programs available",
|
"no_programs": "Keine Programme verfügbar",
|
||||||
"no_channels": "No channels available",
|
"no_channels": "Keine Kanäle verfügbar",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"programs": "Programs",
|
"programs": "Programme",
|
||||||
"guide": "Guide",
|
"guide": "Führer",
|
||||||
"channels": "Channels",
|
"channels": "Kanäle",
|
||||||
"recordings": "Recordings",
|
"recordings": "Aufzeichnungen",
|
||||||
"schedule": "Schedule",
|
"schedule": "Planung",
|
||||||
"series": "Series"
|
"series": "Serien"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
@@ -851,12 +851,12 @@
|
|||||||
"decline": "Ablehnen",
|
"decline": "Ablehnen",
|
||||||
"requested_by": "Angefragt von {{user}}",
|
"requested_by": "Angefragt von {{user}}",
|
||||||
"unknown_user": "Unbekannter Nutzer",
|
"unknown_user": "Unbekannter Nutzer",
|
||||||
"select": "Select",
|
"select": "Auswählen",
|
||||||
"request_all": "Request All",
|
"request_all": "Request All",
|
||||||
"request_seasons": "Request Seasons",
|
"request_seasons": "Request Seasons",
|
||||||
"select_seasons": "Select Seasons",
|
"select_seasons": "Select Seasons",
|
||||||
"request_selected": "Request Selected",
|
"request_selected": "Request Selected",
|
||||||
"n_selected": "{{count}} selected",
|
"n_selected": "{{count}} ausgewählt",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
"jellyseer_does_not_meet_requirements": "Seerr-Server erfüllt nicht die minimalen Versionsanforderungen. Bitte den Seerr-Server auf mindestens 2.0.0 aktualisieren.",
|
||||||
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
"jellyseerr_test_failed": "Seerr-Test fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
@@ -877,7 +877,7 @@
|
|||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
"custom_links": "Links",
|
"custom_links": "Links",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"settings": "Settings"
|
"settings": "Einstellungen"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "Musik",
|
"title": "Musik",
|
||||||
@@ -1004,34 +1004,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"companion_login": {
|
"companion_login": {
|
||||||
"title": "Pair with TV",
|
"title": "Mit TV koppeln",
|
||||||
"align_qr": "Align the QR code within the frame",
|
"align_qr": "Den QR-Code innerhalb des Rahmens ausrichten",
|
||||||
"enter_code_manually": "Enter code manually",
|
"enter_code_manually": "Code manuell eingeben",
|
||||||
"pairing_enter_credentials": "Enter credentials for TV",
|
"pairing_enter_credentials": "Anmeldedaten für TV eingeben",
|
||||||
"pairing_code_label": "Pairing code",
|
"pairing_code_label": "Kopplungscode",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"authorize_button": "Authorize",
|
"authorize_button": "Autorisieren",
|
||||||
"authorizing": "Authorizing...",
|
"authorizing": "Autorisieren...",
|
||||||
"scan_again": "Scan Again",
|
"scan_again": "Scan Again",
|
||||||
"done": "Done",
|
"done": "Fertig",
|
||||||
"success_title": "Authorization Sent",
|
"success_title": "Authorization Sent",
|
||||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
"pairing_tv_connecting": "Der Fernseher verbindet sich mit Ihrem Konto",
|
||||||
"error_title": "Authorization Failed",
|
"error_title": "Authorization Failed",
|
||||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
"error_invalid_qr": "Ungültiger QR-Code. Bitte scannen Sie den TV-Kopplungscode.",
|
||||||
"error_generic": "Something went wrong. Please try again.",
|
"error_generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
"error_permission_denied": "Kameraberechtigung erforderlich zum Scannen von QR-Codes.",
|
||||||
"login_as": "Log in as {{username}}?",
|
"login_as": "Als {{username}} anmelden?",
|
||||||
"on_server": "on {{server}}",
|
"on_server": "auf {{server}}",
|
||||||
"use_different_user": "Use a different user",
|
"use_different_user": "Verwende einen anderen Benutzer",
|
||||||
"open_settings": "Open Settings"
|
"open_settings": "Einstellungen öffnen"
|
||||||
},
|
},
|
||||||
"pairing": {
|
"pairing": {
|
||||||
"pair_with_phone": "Pair with Phone",
|
"pair_with_phone": "Pair with Phone",
|
||||||
"pair_with_phone_title": "Login TV",
|
"pair_with_phone_title": "Login TV",
|
||||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
"pair_with_phone_description": "Scannen Sie den QR-Code, der auf Ihrem Fernseher angezeigt wird, um sich anzumelden",
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
"waiting_for_phone": "Warte auf Telefon...",
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
"scan_with_phone": "Scanne mit der Streamyfin-App auf deinem Handy",
|
||||||
"logging_in": "Logging in...",
|
"logging_in": "Anmeldung...",
|
||||||
"logging_in_description": "Connecting to your server"
|
"logging_in_description": "Verbinde mit deinem Server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,7 @@
|
|||||||
"caching_title": "Almacenando en caché",
|
"caching_title": "Almacenando en caché",
|
||||||
"caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.",
|
"caching_description": "Cachear automáticamente las próximas canciones para una reproducción más suave.",
|
||||||
"lookahead_enabled": "Activar el look-Ahead Cache",
|
"lookahead_enabled": "Activar el look-Ahead Cache",
|
||||||
"lookahead_count": "",
|
"lookahead_count": "Songs to pre-cache",
|
||||||
"max_cache_size": "Tamaño máximo del caché"
|
"max_cache_size": "Tamaño máximo del caché"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"error_title": "Errore",
|
"error_title": "Errore",
|
||||||
"login_title": "Accesso",
|
"login_title": "Accesso",
|
||||||
"login_to_title": "Accedi a",
|
"login_to_title": "Accedi a",
|
||||||
"select_user": "Select a user to log in",
|
"select_user": "Seleziona un utente per accedere",
|
||||||
"add_user_to_login": "Add a user to log in",
|
"add_user_to_login": "Aggiungi un utente per accedere",
|
||||||
"add_user": "Add User",
|
"add_user": "Add User",
|
||||||
"username_placeholder": "Nome utente",
|
"username_placeholder": "Nome utente",
|
||||||
"password_placeholder": "Password",
|
"password_placeholder": "Password",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"connect_button": "Connetti",
|
"connect_button": "Connetti",
|
||||||
"previous_servers": "server precedente",
|
"previous_servers": "server precedente",
|
||||||
"clear_button": "Cancella",
|
"clear_button": "Cancella",
|
||||||
"swipe_to_remove": "Swipe to remove",
|
"swipe_to_remove": "Scorri per rimuovere",
|
||||||
"search_for_local_servers": "Ricerca dei server locali",
|
"search_for_local_servers": "Ricerca dei server locali",
|
||||||
"searching": "Cercando...",
|
"searching": "Cercando...",
|
||||||
"servers": "Server",
|
"servers": "Server",
|
||||||
@@ -41,21 +41,21 @@
|
|||||||
"session_expired": "Session Expired",
|
"session_expired": "Session Expired",
|
||||||
"please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.",
|
"please_login_again": "La tua sessione è scaduta. Si prega di eseguire nuovamente l'accesso.",
|
||||||
"remove_saved_login": "Remove Saved Login",
|
"remove_saved_login": "Remove Saved Login",
|
||||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
"remove_saved_login_description": "Questo rimuoverà le tue credenziali salvate per questo server. Dovrai inserire nuovamente il tuo nome utente e la password la prossima volta.",
|
||||||
"accounts_count": "{{count}} accounts",
|
"accounts_count": "Account {{count}}",
|
||||||
"select_account": "Select Account",
|
"select_account": "Select Account",
|
||||||
"add_account": "Add Account",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "This will remove the saved credentials for {{username}}.",
|
"remove_account_description": "Questo rimuoverà le credenziali salvate per {{username}}.",
|
||||||
"remove_server": "Remove Server",
|
"remove_server": "Remove Server",
|
||||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
"remove_server_description": "Questo rimuoverà {{server}} e tutti gli account salvati dall'elenco.",
|
||||||
"select_your_server": "Select Your Server",
|
"select_your_server": "Select Your Server",
|
||||||
"add_server_to_get_started": "Add a server to get started",
|
"add_server_to_get_started": "Aggiungi un server per iniziare",
|
||||||
"add_server": "Add Server",
|
"add_server": "Add Server",
|
||||||
"change_server": "Change Server"
|
"change_server": "Change Server"
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "Save Account",
|
"title": "Save Account",
|
||||||
"save_for_later": "Save this account",
|
"save_for_later": "Salva questo account",
|
||||||
"security_option": "Security Option",
|
"security_option": "Security Option",
|
||||||
"no_protection": "No protection",
|
"no_protection": "No protection",
|
||||||
"no_protection_desc": "Quick login without authentication",
|
"no_protection_desc": "Quick login without authentication",
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
"local_network": "",
|
"local_network": "Local network",
|
||||||
"auto_switch_enabled": "Auto-switch when at home",
|
"auto_switch_enabled": "Auto-switch when at home",
|
||||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||||
"local_url": "Local URL",
|
"local_url": "Local URL",
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
"title": "Switch User",
|
"title": "Switch User",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"switch_user": "Switch User on This Server",
|
"switch_user": "Switch User on This Server",
|
||||||
"current": "current"
|
"current": "nåværende"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories"
|
"title": "Categories"
|
||||||
|
|||||||
@@ -346,7 +346,7 @@
|
|||||||
"PORTRAIT_DOWN": "Portrait Down",
|
"PORTRAIT_DOWN": "Portrait Down",
|
||||||
"LANDSCAPE": "Landscape",
|
"LANDSCAPE": "Landscape",
|
||||||
"LANDSCAPE_LEFT": "Landscape Left",
|
"LANDSCAPE_LEFT": "Landscape Left",
|
||||||
"LANDSCAPE_RIGHT": "",
|
"LANDSCAPE_RIGHT": "Landscape right",
|
||||||
"OTHER": "Other",
|
"OTHER": "Other",
|
||||||
"UNKNOWN": "Unknown"
|
"UNKNOWN": "Unknown"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user