feat: wylaczalny auto-rotate w playerze + '+N' liczy takze filmy
Some checks are pending
Backend tests / test (push) Waiting to run
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:
parent
9af5fd4eb4
commit
90a63b1e77
6 changed files with 138 additions and 7 deletions
|
|
@ -27,7 +27,7 @@ from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.device import get_device_id
|
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}
|
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):
|
class FavoriteOut(BaseModel):
|
||||||
performer_id: uuid.UUID
|
performer_id: uuid.UUID
|
||||||
canonical_name: str
|
canonical_name: str
|
||||||
|
|
@ -162,7 +219,7 @@ def list_favorites(
|
||||||
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
# _job_refresh_taxonomy_counts) — ta sama definicja co przed (sceny z żywym
|
||||||
# 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_counts = _new_counts(session, device_id, kind="performer")
|
new_counts = _merged_new_counts(session, device_id, kind="performer")
|
||||||
|
|
||||||
items: list[FavoriteOut] = []
|
items: list[FavoriteOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
@ -277,7 +334,7 @@ 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_counts = _new_counts(session, device_id, kind="studio")
|
new_counts = _merged_new_counts(session, device_id, kind="studio")
|
||||||
|
|
||||||
items: list[FavoriteStudioOut] = []
|
items: list[FavoriteStudioOut] = []
|
||||||
new_total = 0
|
new_total = 0
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DefaultQuality,
|
DefaultQuality,
|
||||||
|
getAutoRotate,
|
||||||
getDefaultQuality,
|
getDefaultQuality,
|
||||||
getGridColumns,
|
getGridColumns,
|
||||||
|
setAutoRotate as persistAutoRotate,
|
||||||
setDefaultQuality as persistDefaultQuality,
|
setDefaultQuality as persistDefaultQuality,
|
||||||
setGridColumns as persistGridColumns,
|
setGridColumns as persistGridColumns,
|
||||||
} from './storage';
|
} from './storage';
|
||||||
|
|
@ -17,6 +19,8 @@ interface Prefs {
|
||||||
setGridColumns: (n: number) => void;
|
setGridColumns: (n: number) => void;
|
||||||
defaultQuality: DefaultQuality;
|
defaultQuality: DefaultQuality;
|
||||||
setDefaultQuality: (q: DefaultQuality) => void;
|
setDefaultQuality: (q: DefaultQuality) => void;
|
||||||
|
autoRotate: boolean;
|
||||||
|
setAutoRotate: (on: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ctx = createContext<Prefs | null>(null);
|
const Ctx = createContext<Prefs | null>(null);
|
||||||
|
|
@ -24,6 +28,7 @@ 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');
|
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
|
||||||
|
const [autoRotate, setRotate] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getGridColumns()
|
getGridColumns()
|
||||||
|
|
@ -32,6 +37,9 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
|
||||||
getDefaultQuality()
|
getDefaultQuality()
|
||||||
.then(setQuality)
|
.then(setQuality)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
getAutoRotate()
|
||||||
|
.then(setRotate)
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setGridColumns = (n: number) => {
|
const setGridColumns = (n: number) => {
|
||||||
|
|
@ -44,8 +52,22 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
|
||||||
persistDefaultQuality(q).catch(() => {});
|
persistDefaultQuality(q).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setAutoRotate = (on: boolean) => {
|
||||||
|
setRotate(on);
|
||||||
|
persistAutoRotate(on).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}>
|
<Ctx.Provider
|
||||||
|
value={{
|
||||||
|
gridColumns,
|
||||||
|
setGridColumns,
|
||||||
|
defaultQuality,
|
||||||
|
setDefaultQuality,
|
||||||
|
autoRotate,
|
||||||
|
setAutoRotate,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-07-26b',
|
||||||
date: 'July 2026',
|
date: 'July 2026',
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,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, defaultQuality, setDefaultQuality, autoRotate, setAutoRotate } = usePreferences();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -376,6 +376,24 @@ export function AppLockSettingsScreen() {
|
||||||
</View>
|
</View>
|
||||||
</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}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Backup & sync</Text>
|
<Text style={styles.sectionTitle}>Backup & sync</Text>
|
||||||
<Text style={styles.hint}>
|
<Text style={styles.hint}>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { setLastPlayback } from '../lib/lastPlayback';
|
import { setLastPlayback } from '../lib/lastPlayback';
|
||||||
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
|
import { allowScreenOff, keepScreenOn } from '../lib/keepAwake';
|
||||||
|
import { usePreferences } from '../PreferencesContext';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
|
@ -135,12 +136,20 @@ export function PlayerScreen() {
|
||||||
const params = route.params as RouteParams;
|
const params = route.params as RouteParams;
|
||||||
const mode = params.mode ?? detectMode(params.url);
|
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(() => {
|
React.useEffect(() => {
|
||||||
ScreenOrientation.unlockAsync().catch(() => {});
|
if (autoRotate) ScreenOrientation.unlockAsync().catch(() => {});
|
||||||
|
else {
|
||||||
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {});
|
||||||
};
|
};
|
||||||
}, []);
|
}, [autoRotate]);
|
||||||
|
|
||||||
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
|
if (mode === 'webview') return <EmbedWebViewPlayer params={params} />;
|
||||||
return <NativeVideoPlayer params={params} />;
|
return <NativeVideoPlayer params={params} />;
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,23 @@ export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
|
||||||
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
|
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 {
|
export interface Credentials {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue