Compare commits

..

No commits in common. "93f4d05df8dc64df1f7a188eccd5af56cad02ff7" and "2f68b118c65876dd276c1c5a40ba819f472da398" have entirely different histories.

9 changed files with 59 additions and 295 deletions

View file

@ -39,98 +39,13 @@ from app.models.favorite_studio import FavoriteStudio
from app.models.movie import Movie from app.models.movie import Movie
from app.models.performer import Performer from app.models.performer import Performer
from app.models.playback_source import PlaybackSource from app.models.playback_source import PlaybackSource
from app.models.scene import Scene, ScenePerformer, SceneTag from app.models.scene import Scene, ScenePerformer
from app.models.studio import Studio from app.models.studio import Studio
router = APIRouter( router = APIRouter(
prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)] prefix="/favorites", tags=["favorites"], dependencies=[Depends(require_api_key)]
) )
# Licznik "+N nowych" MUSI liczyć to samo, co user zobaczy po wejściu na listę scen
# performerki/studia — inaczej pokazuje +6 a lista ma 0 (report: „+6 a nic nowego").
# Rozjazdy które to powodowały: (1) licznik nie nakładał blacklist device (gay-filter/
# ukryte tagi) którą lista nakłada, (2) nie ograniczał do pierwszej strony listy
# (per_page=200, sort release_date desc) — nowa scena o starej dacie wydania wpadała
# poza top-200 albo w ogóle poza to co widać, (3) dla studiów nie odsiewał stub-scen.
# Poniższe replikuje filtry i okno listy (app/api/scenes.py list_scenes, domyślne paramy
# mobile: has_playback=true, include_stubs=false). Trzymane ręcznie w zgodzie z tamtym.
_FAVORITES_PAGE_CAP = 200 # == per_page w PerformerScenesScreen/StudioScenesScreen
def _visible_scene_clauses(session: Session, device_id: str, *, apply_stub: bool) -> list:
"""Klauzule WHERE = filtry widoczności listy scen: żywy playback_source +
blacklisty device + (opcjonalnie) odsianie stub-scen. Aplikowane na zapytanie
z Scene w FROM."""
from sqlalchemy import exists
clauses = [
exists(
select(1).where(
PlaybackSource.scene_id == Scene.id,
PlaybackSource.dead_at.is_(None),
)
)
]
from app.api.scenes import _blacklists_empty
if not _blacklists_empty(session, device_id):
from app.models.blacklist import (
BlacklistedPerformer,
BlacklistedStudio,
BlacklistedTag,
)
clauses.append(
~exists(
select(1)
.select_from(ScenePerformer)
.join(
BlacklistedPerformer,
(BlacklistedPerformer.performer_id == ScenePerformer.performer_id)
& (BlacklistedPerformer.device_id == device_id),
)
.where(ScenePerformer.scene_id == Scene.id)
)
)
clauses.append(
~Scene.studio_id.in_(
select(BlacklistedStudio.studio_id).where(
BlacklistedStudio.device_id == device_id
)
)
)
clauses.append(
~exists(
select(1)
.select_from(SceneTag)
.join(
BlacklistedTag,
(BlacklistedTag.tag_id == SceneTag.tag_id)
& (BlacklistedTag.device_id == device_id),
)
.where(SceneTag.scene_id == Scene.id)
)
)
if apply_stub:
# Stub = tube-only scena bez release_date AND bez canonical (TPDB/StashDB) AND
# bez performera. Dla widoku performerki nigdy nie zachodzi (ma performera), więc
# apply_stub=False tam; dla studiów tak. Lustro scenes.py:348-367.
from app.models.scene import SceneExternalRef
from app.models.source import Source, SourceKind
canonical_exists = exists(
select(1)
.select_from(SceneExternalRef)
.join(Source, Source.id == SceneExternalRef.source_id)
.where(SceneExternalRef.scene_id == Scene.id)
.where(Source.kind.in_([SourceKind.tpdb, SourceKind.stashdb]))
)
has_performer = exists(select(1).where(ScenePerformer.scene_id == Scene.id))
clauses.append(
Scene.release_date.is_not(None) | canonical_exists | has_performer
)
return clauses
class FavoriteOut(BaseModel): class FavoriteOut(BaseModel):
performer_id: uuid.UUID performer_id: uuid.UUID
@ -170,36 +85,39 @@ def list_favorites(
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019. # playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows} scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
# new_count per performer = sceny created_at > last_seen_at, ale liczone TYLKO wśród # Batch: new_count per performer — sceny z created_at > last_seen_at favorite'a.
# tych, które user faktycznie zobaczy na liście: te same filtry (żywy playback + # Każda performerka ma INNY last_seen_at, więc warunek per-row. Trick: GREATEST jest
# blacklist) i to samo okno (top-_FAVORITES_PAGE_CAP pod release_date desc) co # nieważny — robimy CASE per row z mapowaniem perf_id → last_seen przez VALUES list.
# PerformerScenes. apply_stub=False — widok performerki i tak ma performera (nie-stub). # Prościej: jeden join + WHERE z OR po wszystkich (perf_id=X AND created_at>ts_X) —
# ale to N OR-ów. Najczystsze rozwiązanie: zapytaj per-row ale wszystkie naraz w
# SQL używając IN tuple lub sub-query. Tu korzystamy z faktu że N=14 typowo, więc
# robimy unionall albo prosty (perf_id, last_seen_at) JOIN.
new_counts: dict = {} new_counts: dict = {}
if perf_ids: if perf_ids:
from sqlalchemy import func # Liczymy TYLKO sceny z żywym playback_source (has_live_playback). Powód:
# TPDB/StashDB sync wstawia metadata-only stubs (52 scen Danielle Renae jednego
clauses = _visible_scene_clauses(session, device_id, apply_stub=False) # dnia z 0 playback) — bumpują created_at, badge `+N`, ale w PerformerScenes
rn = func.row_number().over( # mobile filtruje `has_playback=true` → 0 widocznych. Result: user widzi +48
partition_by=ScenePerformer.performer_id, # ale w profilu nic nowego. Filter aligns count z faktycznie oglądalnym
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), # contentem ("new znalezisko" = scena którą da się odtworzyć).
).label("rn") from sqlalchemy import and_, exists
inner = ( live_playback = exists().where(
select( and_(
ScenePerformer.performer_id.label("gid"), PlaybackSource.scene_id == Scene.id,
Scene.created_at.label("created_at"), PlaybackSource.dead_at.is_(None),
rn,
) )
)
per_scene_rows = session.execute(
select(ScenePerformer.performer_id, Scene.created_at)
.join(Scene, Scene.id == ScenePerformer.scene_id) .join(Scene, Scene.id == ScenePerformer.scene_id)
.where(ScenePerformer.performer_id.in_(perf_ids)) .where(ScenePerformer.performer_id.in_(perf_ids))
.where(*clauses) .where(live_playback)
.subquery() ).all()
) for pid, created_at in per_scene_rows:
for gid, created_at in session.execute( if created_at is None:
select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP) continue
): if created_at > last_seen_by_perf.get(pid):
ls = last_seen_by_perf.get(gid) new_counts[pid] = new_counts.get(pid, 0) + 1
if created_at is not None and ls is not None and created_at > ls:
new_counts[gid] = new_counts.get(gid, 0) + 1
items: list[FavoriteOut] = [] items: list[FavoriteOut] = []
new_total = 0 new_total = 0
@ -318,34 +236,26 @@ def list_favorite_studios(
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019). # scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
scene_counts: dict = {st.id: st.scene_count for _, st in rows} scene_counts: dict = {st.id: st.scene_count for _, st in rows}
# new_count per studio — jak dla performerów (patrz list_favorites): te same filtry
# + okno co lista StudioScenes. apply_stub=True: studio-scena bez performera/release/
# canonical to stub który lista odsiewa, więc licznik też musi.
new_counts: dict = {} new_counts: dict = {}
if studio_ids: if studio_ids:
from sqlalchemy import func # has_live_playback filter — patrz `list_favorites` (performers) wyżej.
from sqlalchemy import and_, exists
clauses = _visible_scene_clauses(session, device_id, apply_stub=True) live_playback = exists().where(
rn = func.row_number().over( and_(
partition_by=Scene.studio_id, PlaybackSource.scene_id == Scene.id,
order_by=(Scene.release_date.desc().nullslast(), Scene.created_at.desc()), PlaybackSource.dead_at.is_(None),
).label("rn")
inner = (
select(
Scene.studio_id.label("gid"),
Scene.created_at.label("created_at"),
rn,
) )
)
per_scene_rows = session.execute(
select(Scene.studio_id, Scene.created_at)
.where(Scene.studio_id.in_(studio_ids)) .where(Scene.studio_id.in_(studio_ids))
.where(*clauses) .where(live_playback)
.subquery() ).all()
) for sid, created_at in per_scene_rows:
for gid, created_at in session.execute( if created_at is None:
select(inner.c.gid, inner.c.created_at).where(inner.c.rn <= _FAVORITES_PAGE_CAP) continue
): if created_at > last_seen_by_studio.get(sid):
ls = last_seen_by_studio.get(gid) new_counts[sid] = new_counts.get(sid, 0) + 1
if created_at is not None and ls is not None and created_at > ls:
new_counts[gid] = new_counts.get(gid, 0) + 1
items: list[FavoriteStudioOut] = [] items: list[FavoriteStudioOut] = []
new_total = 0 new_total = 0

View file

@ -1,37 +1,26 @@
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
import { import { getGridColumns, setGridColumns as persistGridColumns } from './storage';
DefaultQuality,
getDefaultQuality,
getGridColumns,
setDefaultQuality as persistDefaultQuality,
setGridColumns as persistGridColumns,
} from './storage';
/** /**
* Globalne preferencje UI. Ładowane raz przy montażu z SecureStore (async) i dalej * Globalne preferencje UI (na razie tylko liczba kolumn siatki scen). Ładowane raz
* trzymane synchronicznie w state, żeby ekrany czytały wartość w renderze bez await. * przy montażu z SecureStore (async) i dalej trzymane synchronicznie w state, żeby
* ekrany siatek mogły czytać wartość w renderze bez await. Default 2 do czasu załadowania.
*/ */
interface Prefs { interface Prefs {
gridColumns: number; gridColumns: number;
setGridColumns: (n: number) => void; setGridColumns: (n: number) => void;
defaultQuality: DefaultQuality;
setDefaultQuality: (q: DefaultQuality) => void;
} }
const Ctx = createContext<Prefs | null>(null); const Ctx = createContext<Prefs | null>(null);
export function PreferencesProvider({ children }: { children: React.ReactNode }) { export function PreferencesProvider({ children }: { children: React.ReactNode }) {
const [gridColumns, setCols] = useState(2); const [gridColumns, setCols] = useState(2);
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
useEffect(() => { useEffect(() => {
getGridColumns() getGridColumns()
.then(setCols) .then(setCols)
.catch(() => {}); .catch(() => {});
getDefaultQuality()
.then(setQuality)
.catch(() => {});
}, []); }, []);
const setGridColumns = (n: number) => { const setGridColumns = (n: number) => {
@ -39,16 +28,7 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
persistGridColumns(n).catch(() => {}); persistGridColumns(n).catch(() => {});
}; };
const setDefaultQuality = (q: DefaultQuality) => { return <Ctx.Provider value={{ gridColumns, setGridColumns }}>{children}</Ctx.Provider>;
setQuality(q);
persistDefaultQuality(q).catch(() => {});
};
return (
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}>
{children}
</Ctx.Provider>
);
} }
export function usePreferences(): Prefs { export function usePreferences(): Prefs {

View file

@ -16,16 +16,6 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-01',
date: 'July 2026',
items: [
'Set a default video quality in Settings → Playback (e.g. 720p) to skip the quality chooser when that quality is available.',
'The "+N new" count on a favorite now matches what you actually see when you open it — no more "+6" with nothing new.',
'Smoother scrolling: when fresh scenes land at the top of a list, it no longer jumps under your finger.',
'Fixed: a video that plays fine no longer flashes "Playback failed / Mark broken" while it reconnects.',
],
},
{ {
id: '2026-06-29b', id: '2026-06-29b',
date: 'June 2026', date: 'June 2026',

View file

@ -269,10 +269,5 @@ export function sceneGridProps(cols: number) {
maxToRenderPerBatch: 8, maxToRenderPerBatch: 8,
initialNumToRender: 8, initialNumToRender: 8,
updateCellsBatchingPeriod: 50, updateCellsBatchingPeriod: 50,
// Anti-jank (report): sort=created_at desc → scrapery dosypują nowe sceny NA GÓRĘ.
// Gdy refetch wstawi je nad viewportem, treść skacze/tearing pod palcem. RN pinuje
// widoczny element (minIndexForVisible:0) i sam koryguje offset. autoscrollToTopThreshold:
// gdy user jest przy samej górze (<48px), nowe wskakują normalnie; niżej lista stoi.
maintainVisibleContentPosition: { minIndexForVisible: 0, autoscrollToTopThreshold: 48 },
}; };
} }

View file

@ -27,7 +27,6 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { APP_VERSION } from '../lib/appVersion'; import { APP_VERSION } from '../lib/appVersion';
import { replayOnboarding } from '../lib/onboardingBus'; import { replayOnboarding } from '../lib/onboardingBus';
import { usePreferences } from '../PreferencesContext'; import { usePreferences } from '../PreferencesContext';
import type { DefaultQuality } from '../storage';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import { PinEntry } from './PinEntry'; import { PinEntry } from './PinEntry';
@ -40,15 +39,6 @@ const TIMEOUT_OPTIONS: { label: string; seconds: number }[] = [
{ label: '15 min', seconds: 900 }, { label: '15 min', seconds: 900 },
]; ];
const QUALITY_OPTIONS: { label: string; value: DefaultQuality }[] = [
{ label: 'Ask', value: 'auto' },
{ label: '4K', value: 2160 },
{ label: '1080p', value: 1080 },
{ label: '720p', value: 720 },
{ label: '480p', value: 480 },
{ label: 'Lowest', value: 'lowest' },
];
type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-confirm'; type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-confirm';
export function AppLockSettingsScreen() { export function AppLockSettingsScreen() {
@ -57,7 +47,7 @@ export function AppLockSettingsScreen() {
const [stage, setStage] = useState<Stage>('menu'); const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState(''); const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null); const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences(); const { gridColumns, setGridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
async function refresh() { async function refresh() {
@ -307,30 +297,6 @@ export function AppLockSettingsScreen() {
</View> </View>
</View> </View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Playback</Text>
<Text style={styles.hint}>
Default quality auto-picked when available so you skip the chooser. "Ask" shows
the quality menu every time (movie parts always ask).
</Text>
<View style={styles.chipRow}>
{QUALITY_OPTIONS.map((opt) => {
const active = defaultQuality === opt.value;
return (
<Pressable
key={String(opt.value)}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setDefaultQuality(opt.value)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text> <Text style={styles.sectionTitle}>About</Text>
<View style={styles.row}> <View style={styles.row}>

View file

@ -10,7 +10,6 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { theme } from '../theme'; import { theme } from '../theme';
import type { DefaultQuality } from '../storage';
import type { StreamLink } from '../types'; import type { StreamLink } from '../types';
export function qualityToInt(q: string | null | undefined): number { export function qualityToInt(q: string | null | undefined): number {
@ -30,35 +29,6 @@ export function sortByQualityDesc(links: StreamLink[]): StreamLink[] {
return [...links].sort((a, b) => qualityToInt(b.quality) - qualityToInt(a.quality)); return [...links].sort((a, b) => qualityToInt(b.quality) - qualityToInt(a.quality));
} }
/**
* Wybierz link pasujący do domyślnej jakości usera; `null` pokaż picker (auto, brak
* dopasowania, albo same nieznane jakości). Reguła: największa wysokość <= target;
* gdy nic <= target (wszystkie wyższe) najbliższa. 'lowest' najmniejsza dodatnia.
* Linki o nieznanej wysokości (qualityToInt=0) pomijamy gdy jest jakikolwiek znany, żeby
* nieopisany link nie udawał wybranej jakości. NIE dotyczy movie parts (tam preserveOrder).
*/
export function pickByDefaultQuality(
links: StreamLink[],
pref: DefaultQuality,
): StreamLink | null {
if (pref === 'auto' || links.length === 0) return null;
const known = links
.map((l) => ({ l, px: qualityToInt(l.quality) }))
.filter((x) => x.px > 0);
if (known.length === 0) return null;
if (pref === 'lowest') {
return known.reduce((a, b) => (b.px < a.px ? b : a)).l;
}
const target = pref;
const atOrBelow = known.filter((x) => x.px <= target);
if (atOrBelow.length > 0) {
return atOrBelow.reduce((a, b) => (b.px > a.px ? b : a)).l; // największa <= target
}
return known.reduce((a, b) =>
Math.abs(b.px - target) < Math.abs(a.px - target) ? b : a,
).l; // wszystkie wyższe → najbliższa
}
export function PlaybackQualityModal({ export function PlaybackQualityModal({
visible, visible,
links, links,

View file

@ -698,18 +698,6 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
? panSeekTarget ? panSeekTarget
: position; : position;
// Czy jest jeszcze JAKAKOLWIEK ścieżka ratunku w toku? Gdy tak — NIE pokazuj
// terminalnego błędu z „Mark broken", bo za chwilę zagra (report dafa8cdb: eporner/
// sxyprn/fpoxxx native pada na starcie, re-resolve podmienia URL ~1-3s później i gra;
// przez tę chwilę migało „Playback failed / Mark broken"). Lustro logiki telemetrii:
// re-resolve IP-bound, in-place seek-recovery, proxy albo WebView jeszcze nie próbowane.
const recoveryPending =
status === 'error' &&
((!!resolvePageUrl && !reResolveDone && !loadedOnceRef.current) ||
(loadedOnceRef.current && seekRecoveryRef.current < 2 && !isGoneError(playerError?.message)) ||
(!!fallbackProxyUrl && !didFallbackProxyRef.current && url !== fallbackProxyUrl) ||
(!!fallbackEmbedUrl && !didFallbackWebViewRef.current));
return ( return (
<View style={styles.root} onLayout={onLayout}> <View style={styles.root} onLayout={onLayout}>
{/* Hidden status bar — full-bleed video w landscape bez 24px paska systemu. */} {/* Hidden status bar — full-bleed video w landscape bez 24px paska systemu. */}
@ -829,7 +817,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
<Text style={styles.overlayText}>{title ?? 'Loading…'}</Text> <Text style={styles.overlayText}>{title ?? 'Loading…'}</Text>
</View> </View>
)} )}
{status === 'error' && !recoveryPending && ( {status === 'error' && !fallbackEmbedUrl && (
<View style={styles.overlay}> <View style={styles.overlay}>
<Text style={styles.errorTitle}> <Text style={styles.errorTitle}>
{isGoneError(playerError?.message) ? 'Source no longer available' : 'Playback failed'} {isGoneError(playerError?.message) ? 'Source no longer available' : 'Playback failed'}
@ -851,14 +839,10 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
</View> </View>
</View> </View>
)} )}
{recoveryPending && ( {status === 'error' && fallbackEmbedUrl && (
<View style={styles.overlay} pointerEvents="none"> <View style={styles.overlay} pointerEvents="none">
<ActivityIndicator color={theme.fg} size="large" /> <ActivityIndicator color={theme.fg} size="large" />
<Text style={styles.overlayText}> <Text style={styles.overlayText}>Native player failed switching to embed</Text>
{fallbackEmbedUrl && !didFallbackWebViewRef.current
? 'Native player failed — switching to embed…'
: 'Reconnecting…'}
</Text>
</View> </View>
)} )}
</View> </View>

View file

@ -28,8 +28,7 @@ import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { PlaybackSource, SceneOut, StreamLink } from '../types'; import type { PlaybackSource, SceneOut, StreamLink } from '../types';
import { PlaybackQualityModal, pickByDefaultQuality } from './PlaybackQualityModal'; import { PlaybackQualityModal } from './PlaybackQualityModal';
import { usePreferences } from '../PreferencesContext';
export function SceneDetailScreen() { export function SceneDetailScreen() {
const client = useClient(); const client = useClient();
@ -480,17 +479,8 @@ function PlaybackButton({
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>(); const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const { defaultQuality } = usePreferences();
const [resolving, setResolving] = React.useState(false); const [resolving, setResolving] = React.useState(false);
const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null); const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null);
// Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera
// (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje
// per call-site (różni się: pornxp/phone woła przed pickerem, backend tylko gdy gra).
const pickAuto = (list: StreamLink[]): StreamLink | null => {
if (list.length === 1) return list[0];
return pickByDefaultQuality(list, defaultQuality);
};
// Tube origin format: 'tube:hqpornercom' (nowy) lub 'pornapp:hqpornercom' (legacy // Tube origin format: 'tube:hqpornercom' (nowy) lub 'pornapp:hqpornercom' (legacy
// sprzed migracji 0011) — backend rozumie oba prefixy. // sprzed migracji 0011) — backend rozumie oba prefixy.
const isTube = source.origin.startsWith('tube:') || source.origin.startsWith('pornapp:'); const isTube = source.origin.startsWith('tube:') || source.origin.startsWith('pornapp:');
@ -598,8 +588,7 @@ function PlaybackButton({
const links = await resolvePornxpPage(source.page_url); const links = await resolvePornxpPage(source.page_url);
if (links.length > 0) { if (links.length > 0) {
markStarted(); markStarted();
const pick = pickAuto(links); if (links.length === 1) await openAsVideo(links[0], source.page_url);
if (pick) await openAsVideo(pick, source.page_url);
else setQualityLinks(links); else setQualityLinks(links);
return; return;
} }
@ -629,8 +618,7 @@ function PlaybackButton({
const links = await phoneResolver(source.page_url); const links = await phoneResolver(source.page_url);
if (links.length > 0) { if (links.length > 0) {
markStarted(); markStarted();
const pick = pickAuto(links); if (links.length === 1) await openAsVideo(links[0], source.page_url);
if (pick) await openAsVideo(pick, source.page_url);
else setQualityLinks(links); else setQualityLinks(links);
return; return;
} }
@ -700,10 +688,9 @@ function PlaybackButton({
// jest, niezależnie od kolejności w `links`. // jest, niezależnie od kolejności w `links`.
const fallbackEmbedUrl = embedLinks[0]?.embed_url || res.best?.embed_url || undefined; const fallbackEmbedUrl = embedLinks[0]?.embed_url || res.best?.embed_url || undefined;
const autoPick = pickAuto(directLinks); if (directLinks.length === 1) {
if (autoPick) {
markStarted(); markStarted();
await openAsVideo(autoPick, fallbackEmbedUrl); await openAsVideo(directLinks[0], fallbackEmbedUrl);
return; return;
} }
setQualityLinks(directLinks); setQualityLinks(directLinks);

View file

@ -86,24 +86,6 @@ export async function setGridColumns(n: number): Promise<void> {
await SecureStore.setItemAsync(GRID_COLS_KEY, String(n)); await SecureStore.setItemAsync(GRID_COLS_KEY, String(n));
} }
// Domyślna jakość odtwarzania (user-request: mniej klików). Gdy resolve zwróci kilka
// jakości i jest dopasowanie do tej wartości, apka pomija picker i gra od razu. 'auto'
// = zawsze pokaż picker (stare zachowanie). 'lowest' = najniższa dostępna.
export type DefaultQuality = 'auto' | 2160 | 1080 | 720 | 480 | 'lowest';
const DEFAULT_QUALITY_KEY = 'goon.default_quality';
export async function getDefaultQuality(): Promise<DefaultQuality> {
const v = await SecureStore.getItemAsync(DEFAULT_QUALITY_KEY);
if (!v) return 'auto';
if (v === 'auto' || v === 'lowest') return v;
const n = parseInt(v, 10);
return n === 2160 || n === 1080 || n === 720 || n === 480 ? (n as DefaultQuality) : 'auto';
}
export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
}
export interface Credentials { export interface Credentials {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;