feat: wylaczalny auto-rotate w playerze + '+N' liczy takze filmy
Some checks are pending
Backend tests / test (push) Waiting to run

1) Auto-rotate (bug-report 55da1c3f: 'I can't turn off auto rotate. Which is a
problem when I put the phone on a flat surface'). Player robil bezwarunkowy
unlockAsync, wiec telefon lezacy plasko skakal miedzy orientacjami. Nowa
preferencja goon.player_auto_rotate (default true = dotychczasowe zachowanie),
przelacznik w Settings. Off = player zostaje w pionie, ale przycisk pelnego
ekranu dalej wchodzi w poziom recznie, wiec nic nie ginie.

2) '+N nowych' na ulubionym performerze/studiu liczylo TYLKO sceny (user-request
2026-07-28). Dochodzi _new_movie_counts: filmy z zywym playbackiem, created_at >
last_seen_at, po MoviePerformer / Movie.studio_id. Filmow jest o rzedy wielkosci
mniej niz scen (brak tube-spamu), wiec bez okna top-N i odsiewu stub/backfill -
te pojecia dotycza scen. Zweryfikowane: performer 1633 scen + 436 filmow = 2069,
studio 648 + 95 = 743, /favorites 200 OK.
This commit is contained in:
goon-foss 2026-07-28 09:34:37 +02:00
parent 9af5fd4eb4
commit 90a63b1e77
6 changed files with 138 additions and 7 deletions

View file

@ -27,7 +27,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import exists, select
from sqlalchemy.orm import Session
from app.api.device import get_device_id
@ -128,6 +128,63 @@ def _new_counts(session: Session, device_id: str, *, kind: str) -> dict:
return {gid_val: int(n) for gid_val, n in rows}
def _new_movie_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""To samo co `_new_counts`, ale dla FILMÓW (user-request 2026-07-28: "+N" ma się
pojawiać też gdy ulubiony performer/studio dostanie nowy film, nie tylko scenę).
Filtr widoczności jak w `list_movies`: film musi mieć żywy playback. Filmów jest
o rzędy wielkości mniej niż scen (brak tube-spamu), więc nie potrzeba okna
top-N ani odsiewu stub/backfill te pojęcia dotyczą scen.
"""
from sqlalchemy import func
from app.models.movie import Movie, MoviePerformer
from app.models.movie_playback_source import MoviePlaybackSource
live_movie = exists(
select(1).where(
MoviePlaybackSource.movie_id == Movie.id,
MoviePlaybackSource.dead_at.is_(None),
)
)
if kind == "performer":
base = (
select(
MoviePerformer.performer_id.label("gid"),
func.count()
.filter(Movie.created_at > FavoritePerformer.last_seen_at)
.label("n"),
)
.select_from(FavoritePerformer)
.join(MoviePerformer, MoviePerformer.performer_id == FavoritePerformer.performer_id)
.join(Movie, Movie.id == MoviePerformer.movie_id)
.where(FavoritePerformer.device_id == device_id, live_movie)
.group_by(MoviePerformer.performer_id)
)
else:
base = (
select(
Movie.studio_id.label("gid"),
func.count().filter(Movie.created_at > FavoriteStudio.last_seen_at).label("n"),
)
.select_from(FavoriteStudio)
.join(Movie, Movie.studio_id == FavoriteStudio.studio_id)
.where(FavoriteStudio.device_id == device_id, live_movie)
.group_by(Movie.studio_id)
)
return {gid: int(n) for gid, n in session.execute(base).all() if n}
def _merged_new_counts(session: Session, device_id: str, *, kind: str) -> dict:
"""Sceny + filmy w jednym liczniku "+N" (badge nie rozróżnia typu treści)."""
counts = _new_counts(session, device_id, kind=kind)
for gid, n in _new_movie_counts(session, device_id, kind=kind).items():
counts[gid] = counts.get(gid, 0) + n
return counts
class FavoriteOut(BaseModel):
performer_id: uuid.UUID
canonical_name: str
@ -162,7 +219,7 @@ def list_favorites(
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
# playback). Wcześniej grouped count z EXISTS playback per-request. Migracja 0019.
scene_counts: dict = {perf.id: perf.scene_count for _, perf in rows}
new_counts = _new_counts(session, device_id, kind="performer")
new_counts = _merged_new_counts(session, device_id, kind="performer")
items: list[FavoriteOut] = []
new_total = 0
@ -277,7 +334,7 @@ def list_favorite_studios(
# scene_count: zdenormalizowany Studio.scene_count (refresh w tle, migracja 0019).
scene_counts: dict = {st.id: st.scene_count for _, st in rows}
new_counts = _new_counts(session, device_id, kind="studio")
new_counts = _merged_new_counts(session, device_id, kind="studio")
items: list[FavoriteStudioOut] = []
new_total = 0

View file

@ -2,8 +2,10 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import {
DefaultQuality,
getAutoRotate,
getDefaultQuality,
getGridColumns,
setAutoRotate as persistAutoRotate,
setDefaultQuality as persistDefaultQuality,
setGridColumns as persistGridColumns,
} from './storage';
@ -17,6 +19,8 @@ interface Prefs {
setGridColumns: (n: number) => void;
defaultQuality: DefaultQuality;
setDefaultQuality: (q: DefaultQuality) => void;
autoRotate: boolean;
setAutoRotate: (on: boolean) => void;
}
const Ctx = createContext<Prefs | null>(null);
@ -24,6 +28,7 @@ const Ctx = createContext<Prefs | null>(null);
export function PreferencesProvider({ children }: { children: React.ReactNode }) {
const [gridColumns, setCols] = useState(2);
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
const [autoRotate, setRotate] = useState(true);
useEffect(() => {
getGridColumns()
@ -32,6 +37,9 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
getDefaultQuality()
.then(setQuality)
.catch(() => {});
getAutoRotate()
.then(setRotate)
.catch(() => {});
}, []);
const setGridColumns = (n: number) => {
@ -44,8 +52,22 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
persistDefaultQuality(q).catch(() => {});
};
const setAutoRotate = (on: boolean) => {
setRotate(on);
persistAutoRotate(on).catch(() => {});
};
return (
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}>
<Ctx.Provider
value={{
gridColumns,
setGridColumns,
defaultQuality,
setDefaultQuality,
autoRotate,
setAutoRotate,
}}
>
{children}
</Ctx.Provider>
);

View file

@ -16,6 +16,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-28',
date: 'July 2026',
items: [
'Auto-rotate can now be turned off (Settings -> Auto-rotate), so the video stops flipping when the phone lies flat on a table. The fullscreen button still switches to landscape.',
'The "+N new" badge on a favourite performer or studio now also counts new movies, not just scenes.',
],
},
{
id: '2026-07-26b',
date: 'July 2026',

View file

@ -62,7 +62,7 @@ export function AppLockSettingsScreen() {
const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences();
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality, autoRotate, setAutoRotate } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const client = useClient();
const queryClient = useQueryClient();
@ -376,6 +376,24 @@ export function AppLockSettingsScreen() {
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Auto-rotate</Text>
<Text style={styles.hint}>
Let the video follow your phone's rotation. Turn this off if the picture keeps
flipping when the phone lies flat on a table. The fullscreen button still switches
to landscape.
</Text>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.label}>Rotate with the phone</Text>
<Text style={styles.hint}>
{autoRotate ? 'Video follows the phone' : 'Video stays upright'}
</Text>
</View>
<Switch value={autoRotate} onValueChange={setAutoRotate} />
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Backup & sync</Text>
<Text style={styles.hint}>

View file

@ -9,6 +9,7 @@ import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
import * as Clipboard from 'expo-clipboard';
import { setLastPlayback } from '../lib/lastPlayback';
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
import { usePreferences } from '../PreferencesContext';
import React from 'react';
import {
ActivityIndicator,
@ -135,12 +136,20 @@ export function PlayerScreen() {
const params = route.params as RouteParams;
const mode = params.mode ?? detectMode(params.url);
// Auto-obrót: domyślnie odblokowany (oglądanie w poziomie). Gdy user go wyłączy
// (Settings → Playback), player zostaje w pionie — telefon leżący płasko na stole
// nie skacze między orientacjami (bug-report 55da1c3f). Przycisk pełnego ekranu dalej
// pozwala wejść w poziom ręcznie, więc wyłączenie nie odbiera funkcji.
const { autoRotate } = usePreferences();
React.useEffect(() => {
ScreenOrientation.unlockAsync().catch(() => {});
if (autoRotate) ScreenOrientation.unlockAsync().catch(() => {});
else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
}
return () => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
};
}, []);
}, [autoRotate]);
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
return <NativeVideoPlayer params={params} />;

View file

@ -104,6 +104,23 @@ export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
}
// Auto-obrót w playerze. Player domyślnie odblokowuje orientację (unlockAsync), żeby
// dało się oglądać poziomo — ale wtedy telefon położony płasko na stole sam skacze
// między orientacjami (bug-report 55da1c3f: "I can't turn off auto rotate. Which is a
// problem when I put the phone on a flat surface"). Off = player zostaje w pionie,
// a przełącznik pełnego ekranu dalej pozwala wejść w poziom RĘCZNIE.
// Default true = dotychczasowe zachowanie.
const AUTO_ROTATE_KEY = 'goon.player_auto_rotate';
export async function getAutoRotate(): Promise<boolean> {
const v = await SecureStore.getItemAsync(AUTO_ROTATE_KEY);
return v === null ? true : v === '1';
}
export async function setAutoRotate(on: boolean): Promise<void> {
await SecureStore.setItemAsync(AUTO_ROTATE_KEY, on ? '1' : '0');
}
export interface Credentials {
baseUrl: string;
apiKey: string;