diff --git a/app/api/favorites.py b/app/api/favorites.py index b55098d..7af8f97 100644 --- a/app/api/favorites.py +++ b/app/api/favorites.py @@ -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 diff --git a/mobile/src/PreferencesContext.tsx b/mobile/src/PreferencesContext.tsx index efa9a84..c14df14 100644 --- a/mobile/src/PreferencesContext.tsx +++ b/mobile/src/PreferencesContext.tsx @@ -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(null); @@ -24,6 +28,7 @@ const Ctx = createContext(null); export function PreferencesProvider({ children }: { children: React.ReactNode }) { const [gridColumns, setCols] = useState(2); const [defaultQuality, setQuality] = useState('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 ( - + {children} ); diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 5ece6b5..376665e 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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', diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx index 14fe19c..8e5f1fb 100644 --- a/mobile/src/screens/AppLockSettingsScreen.tsx +++ b/mobile/src/screens/AppLockSettingsScreen.tsx @@ -62,7 +62,7 @@ export function AppLockSettingsScreen() { const [stage, setStage] = useState('menu'); const [newPin, setNewPin] = useState(''); const [errorText, setErrorText] = useState(null); - const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences(); + const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality, autoRotate, setAutoRotate } = usePreferences(); const navigation = useNavigation>(); const client = useClient(); const queryClient = useQueryClient(); @@ -376,6 +376,24 @@ export function AppLockSettingsScreen() { + + Auto-rotate + + 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. + + + + Rotate with the phone + + {autoRotate ? 'Video follows the phone' : 'Video stays upright'} + + + + + + Backup & sync diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index bc6bca0..98340df 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -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 ; return ; diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index b3adc9c..90fc414 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -104,6 +104,23 @@ export async function setDefaultQuality(v: DefaultQuality): Promise { 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 { + const v = await SecureStore.getItemAsync(AUTO_ROTATE_KEY); + return v === null ? true : v === '1'; +} + +export async function setAutoRotate(on: boolean): Promise { + await SecureStore.setItemAsync(AUTO_ROTATE_KEY, on ? '1' : '0'); +} + export interface Credentials { baseUrl: string; apiKey: string;