From 7bfd19448573165fcd32aa9eb42d081e4c4a6334 Mon Sep 17 00:00:00 2001 From: goon-foss Date: Wed, 16 Sep 2026 21:33:44 +0200 Subject: [PATCH] fix(mobile): Edge - serduszko nie rusza dzwieku ani nie przewija klipu, jeden przycisk dzwieku, stroik feedu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zgloszenia Jana 2026-09-16: 1+3. Tapniecie serduszka przelaczalo dzwiek i przewijalo klip na poczatek. Gesty lapala cala komorka razem z przyciskami, wiec tap w serduszko byl tez tapem w ekran, a zmiana dzwieku restartowala odtwarzanie (efekt aktywacji strony mial soundOn w zaleznosciach). Teraz gesty lapie tylko warstwa wideo, a wyciszenie ma osobny efekt. 2. Dwa przyciski dzwieku. Zostaje ten w kolumnie przy kciuku, w gornym pasku wchodzi stroik. 4. Za malo tagow na starcie: bylo 24 z 40, „anal" (27. co do liczby klipow) wypadal. Teraz 60 tagow + nisze + szukanie po calym slowniku (1,7 tys. tagow, nowy param q), wskazanie wazy 8 zamiast 5, a stroik mozna otworzyc w kazdej chwili i zmiana dziala od nastepnego rozdania. 5. Losowe miganie przy przewijaniu: dociagniecie kolejnej strony feedu podmienialo tablice klipow i restartowalo biezacy klip. Efekt zalezy teraz od numeru strony i adresow wideo. Sprawdzone na emulatorze: tap w serduszko zostawia wyciszenie bez zmian i zapisuje like, przycisk dzwieku odcisza, gorny pasek otwiera stroik, szukanie „anal" daje 8 wariantow, a po Apply w clip_affinity siedzi anal=8.0. Edge dalej odklada zdarzenia w clip_feed_events. Co-Authored-By: Claude Opus 5 --- app/api/clips.py | 35 +++++- mobile/src/api.ts | 8 +- mobile/src/changelog.ts | 9 ++ mobile/src/components/swipe/ClipCell.tsx | 68 ++++++------ mobile/src/components/swipe/ClipPager.tsx | 24 +++- mobile/src/screens/SwipeScreen.tsx | 128 +++++++++++++++++++--- 6 files changed, 213 insertions(+), 59 deletions(-) diff --git a/app/api/clips.py b/app/api/clips.py index 4777293..43b71a3 100644 --- a/app/api/clips.py +++ b/app/api/clips.py @@ -487,16 +487,35 @@ _OPTIONS_CACHE: dict[str, tuple[float, OnboardingOptionsOut]] = {} @router.get("/onboarding-options", response_model=OnboardingOptionsOut) -def onboarding_options(session: Session = Depends(get_session)) -> OnboardingOptionsOut: - """Najpopularniejsze tagi i nisze do wyboru „3 tapów" przy pierwszym wejsciu. - Liczone z bazy, cache 10 min w procesie.""" +def onboarding_options( + session: Session = Depends(get_session), + q: str | None = Query(default=None, max_length=64, description="Szukaj w slowniku tagow"), +) -> OnboardingOptionsOut: + """Najpopularniejsze tagi i nisze do wyboru przy pierwszym wejsciu w Edge. + Liczone z bazy, cache 10 min w procesie (szukanie z pominieciem cache). + + `q` szuka po calym slowniku (1,7 tys. tagow), bo sama czolowka nie wystarcza: „anal" + to 27. tag co do liczby klipow i wypadal poza liste (uwaga Jana 2026-09-16).""" + if q and q.strip(): + needle = f"%{q.strip().lower()}%" + found = [ + OnboardingOptionOut(value=t, count=n) + for t, n in session.execute( + select(ClipTag.tag, func.count()) + .where(ClipTag.tag.like(needle)) + .group_by(ClipTag.tag) + .order_by(func.count().desc()) + .limit(30) + ) + ] + return OnboardingOptionsOut(tags=found, niches=[]) hit = _OPTIONS_CACHE.get("v1") if hit and time.monotonic() - hit[0] < 600: return hit[1] tags = [ OnboardingOptionOut(value=t, count=n) for t, n in session.execute( - select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(40) + select(ClipTag.tag, func.count()).group_by(ClipTag.tag).order_by(func.count().desc()).limit(60) ) ] niches = [ @@ -516,15 +535,19 @@ def onboarding( session: Session = Depends(get_session), device_id: str = Depends(get_device_id), ) -> AffinityOut: + # +8 za wskazany tag, wiecej niz polubienie klipu (+5): to jawna deklaracja, a feed + # inaczej zbyt dlugo dochodzil do tego, co user i tak powiedzial na starcie (uwaga + # Jana 2026-09-16). Ten sam endpoint sluzy pozniejszemu dostrajaniu z zakladki Edge, + # wiec kolejne wskazania dokladaja sie do wag. row, aff = _load_affinity(session, device_id) for t in body.tags: t = t.strip().lower()[:64] if t: - aff.tags[t] = round(aff.tags.get(t, 0.0) + 5.0, 3) + aff.tags[t] = round(aff.tags.get(t, 0.0) + 8.0, 3) for n in body.niches: n = n.strip()[:64] if n: - aff.niches[n] = round(aff.niches.get(n, 0.0) + 5.0, 3) + aff.niches[n] = round(aff.niches.get(n, 0.0) + 8.0, 3) _save_affinity(session, device_id, row, aff, 0) session.commit() row2 = session.get(ClipAffinity, device_id) diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 94b5f2d..4eca492 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -669,8 +669,12 @@ export class GoonClient { }); } - async getClipOnboardingOptions(): Promise { - return this.request('/clips/onboarding-options'); + /** Bez `q`: najpopularniejsze tagi i nisze. Z `q`: szukanie po calym slowniku tagow. */ + async getClipOnboardingOptions(q?: string): Promise { + const path = q && q.trim() + ? `/clips/onboarding-options?q=${encodeURIComponent(q.trim())}` + : '/clips/onboarding-options'; + return this.request(path); } async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise { diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 772f4b7..e0e9340 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -35,6 +35,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-09-16c', + date: 'September 2026', + items: [ + 'Edge: tapping the heart no longer restarts the clip or flips the sound on, and the clip no longer jumps back to the start when the next batch loads in the background.', + 'Edge: one sound button instead of two, and a new tuning button at the top. Open it any time to add what you want more of, including a search across every tag we have, and the feed uses it from the next deal on.', + ], + minRuntime: '1.4', + }, { id: '2026-09-16b', date: 'September 2026', diff --git a/mobile/src/components/swipe/ClipCell.tsx b/mobile/src/components/swipe/ClipCell.tsx index 6af24d8..7bc7410 100644 --- a/mobile/src/components/swipe/ClipCell.tsx +++ b/mobile/src/components/swipe/ClipCell.tsx @@ -146,36 +146,43 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike const tags = clip.tags.slice(0, 4); return ( - - - {poster ? ( - - ) : null} - {active && player ? ( - - ) : null} + // Gesty (tap = dzwiek, double-tap = serce, przytrzymanie = 2x, w lewo = „nie + // interesuje") lapie TYLKO warstwa wideo. Wczesniej GestureDetector obejmowal cala + // komorke razem z przyciskami, wiec tapniecie serduszka bylo jednoczesnie tapnieciem + // w ekran i przelaczalo dzwiek (zgloszenie Jana 2026-09-16). + + + + {poster ? ( + + ) : null} + {active && player ? ( + + ) : null} - {/* Wybuch serca po double-tapie */} - - - + {/* Wybuch serca po double-tapie */} + + + + + - + {/* Kolumna przyciskow */} - - - + + ); } diff --git a/mobile/src/components/swipe/ClipPager.tsx b/mobile/src/components/swipe/ClipPager.tsx index 2bc01f1..b2673fe 100644 --- a/mobile/src/components/swipe/ClipPager.tsx +++ b/mobile/src/components/swipe/ClipPager.tsx @@ -109,11 +109,24 @@ export function ClipPager({ }, []); // Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny. + // + // Zaleznosci to NUMER strony i adresy wideo, nie cala tablica `items` ani `soundOn` + // (2026-09-16). Kazda zmiana tamtych rzeczy przewijala klip na poczatek: dociagniecie + // kolejnej strony feedu podmienialo tablice w srodku ogladania („losowo miga i laduje + // sie od nowa"), a wlaczenie dzwieku restartowalo wideo. Wyciszenie ma wlasny efekt + // nizej, `items` czytamy przez ref. const prevIndexRef = React.useRef(null); const leaveRef = React.useRef(onLeave); leaveRef.current = onLeave; + const itemsRef = React.useRef(items); + itemsRef.current = items; + const soundRef = React.useRef(soundOn); + soundRef.current = soundOn; + const activeSrc = items[index] ? clipSource(items[index]) : null; + const nextSrc = items[index + 1] ? clipSource(items[index + 1]) : null; React.useEffect(() => { const ps = players.current; + const items = itemsRef.current; if (!ps.length || !items.length) return; const item = items[index]; if (!item) return; @@ -144,8 +157,8 @@ export function ClipPager({ activatedAtRef.current = Date.now(); loopsRef.current = 0; - ensureSource(active, clipSource(item)); - active.muted = !soundOn; + ensureSource(active, activeSrc); + active.muted = !soundRef.current; active.playbackRate = 1.0; try { active.currentTime = 0; @@ -158,9 +171,8 @@ export function ClipPager({ loopsRef.current += 1; }); - const next = items[index + 1]; - if (next) { - ensureSource(standby, clipSource(next)); + if (nextSrc) { + ensureSource(standby, nextSrc); try { standby.pause(); } catch { @@ -169,7 +181,7 @@ export function ClipPager({ } Haptics.selectionAsync().catch(() => {}); return () => sub.remove(); - }, [index, items, ensureSource, soundOn]); + }, [index, activeSrc, nextSrc, ensureSource]); // Zmiana dzwieku bez zmiany strony. React.useEffect(() => { diff --git a/mobile/src/screens/SwipeScreen.tsx b/mobile/src/screens/SwipeScreen.tsx index 5fc2083..5d43860 100644 --- a/mobile/src/screens/SwipeScreen.tsx +++ b/mobile/src/screens/SwipeScreen.tsx @@ -22,7 +22,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import * as Haptics from 'expo-haptics'; import React from 'react'; -import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { useClient } from '../ClientContext'; import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager'; @@ -56,6 +56,7 @@ export function SwipeScreen() { const [index, setIndex] = React.useState(0); const [liked, setLiked] = React.useState>(new Set()); const [sheetItem, setSheetItem] = React.useState(null); + const [tuneOpen, setTuneOpen] = React.useState(false); const pagerRef = React.useRef(null); const navigation = useNavigation>(); @@ -190,9 +191,23 @@ export function SwipeScreen() { const options = useQuery({ queryKey: ['clip-onboarding-options'], queryFn: () => client.getClipOnboardingOptions(), - enabled: prefsReady && !prefs.onboarded, + enabled: prefsReady && (!prefs.onboarded || tuneOpen), staleTime: 10 * 60 * 1000, }); + // Dostrojenie juz w trakcie: te same wagi co przy pierwszym wejsciu, potem nowe + // rozdanie, zeby zmiana byla widoczna od razu, a nie po wyczerpaniu biezacych stron. + const tuneFeed = React.useCallback( + async (tags: string[], niches: string[]) => { + try { + await client.postClipOnboarding({ tags, niches }); + } catch { + // brak sieci: wagi dojda przy kolejnej probie, feed i tak uczy sie z ogladania + } + newDeal(); + }, + [client, newDeal], + ); + const finishOnboarding = React.useCallback( async (tags: string[], niches: string[]) => { if (tags.length || niches.length) { @@ -258,8 +273,11 @@ export function SwipeScreen() { {prefs.autoSwipeSec ? {prefs.autoSwipeSec}s : null} - - + {/* Dzwiek ma JEDEN przycisk: ten w kolumnie po prawej, przy kciuku (uwaga Jana + 2026-09-16, byly dwa). Tapniecie w wideo dalej przelacza. Tu za to wejscie do + stroika feedu, bo po starcie nie bylo jak dolozyc tagow. */} + setTuneOpen(true)} style={styles.topBtn} accessibilityLabel="Tune my feed"> + @@ -301,12 +319,21 @@ export function SwipeScreen() { ) : null} - {/* Onboarding 3 tapow */} + {/* Wybor zainteresowan: przy pierwszym wejsciu i pozniej spod ikony stroika */} + { + setTuneOpen(false); + if (tags.length || niches.length) void tuneFeed(tags, niches); + }} + /> ); } @@ -325,13 +352,34 @@ function OnboardingModal({ visible, options, onDone, + tune, }: { visible: boolean; options: ClipOnboardingOptions | undefined; onDone: (tags: string[], niches: string[]) => void; + /** true = otwarte z zakladki (dostrajanie), false = pierwsze wejscie. */ + tune?: boolean; }) { + const client = useClient(); const [picked, setPicked] = React.useState>(new Set()); - const tags = (options?.tags ?? []).slice(0, 24); + const [pickedNiches, setPickedNiches] = React.useState>(new Set()); + const [q, setQ] = React.useState(''); + const [debouncedQ, setDebouncedQ] = React.useState(''); + React.useEffect(() => { + const t = setTimeout(() => setDebouncedQ(q.trim()), 300); + return () => clearTimeout(t); + }, [q]); + // Szukanie idzie po CALYM slowniku tagow (1,7 tys.), bo sama czolowka go nie miesci: + // "anal" jest dopiero 27. co do liczby klipow i wypadal z listy (uwaga Jana 2026-09-16). + const search = useQuery({ + queryKey: ['clip-onboarding-search', debouncedQ], + queryFn: () => client.getClipOnboardingOptions(debouncedQ), + enabled: visible && debouncedQ.length >= 2, + staleTime: 5 * 60 * 1000, + }); + const searching = debouncedQ.length >= 2; + const tags = searching ? (search.data?.tags ?? []) : (options?.tags ?? []); + const niches = searching ? [] : (options?.niches ?? []).slice(0, 12); const toggle = (t: string) => setPicked((s) => { const n = new Set(s); @@ -339,27 +387,66 @@ function OnboardingModal({ else n.add(t); return n; }); + const toggleNiche = (t: string) => + setPickedNiches((s) => { + const n = new Set(s); + if (n.has(t)) n.delete(t); + else n.add(t); + return n; + }); + const total = picked.size + pickedNiches.size; return ( - What do you want to see? - Pick three or more. The feed learns from what you watch anyway, this only gives it a head start. - + {tune ? 'More of what you like' : 'What do you want to see?'} + + {tune + ? 'Pick anything you want more of. It counts right away and the next deal uses it.' + : 'Pick three or more, or search for anything that is not on the list. The feed learns from what you watch anyway, this only gives it a head start.'} + + + {tags.map((t) => ( toggle(t.value)} /> ))} + {searching && !search.isLoading && tags.length === 0 ? ( + Nothing matches that. + ) : null} + {niches.map((n) => ( + toggleNiche(n.value)} + /> + ))} onDone([], [])} style={styles.secondaryBtn}> - Skip + {tune ? 'Cancel' : 'Skip'} onDone([...picked], [])} - disabled={picked.size < 3} - style={[styles.primaryBtn, picked.size < 3 && { opacity: 0.4 }]} + onPress={() => onDone([...picked], [...pickedNiches])} + disabled={tune ? total === 0 : total < 3} + style={[styles.primaryBtn, (tune ? total === 0 : total < 3) && { opacity: 0.4 }]} > - Start{picked.size ? ` (${picked.size})` : ''} + + {tune ? 'Apply' : 'Start'} + {total ? ` (${total})` : ''} + @@ -391,6 +478,19 @@ const styles = StyleSheet.create({ onboardBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 18 }, onboardCard: { backgroundColor: theme.card, borderRadius: 18, padding: 18 }, onboardRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 12 }, + search: { + marginTop: 12, + marginBottom: 10, + height: 42, + borderRadius: 12, + paddingHorizontal: 12, + backgroundColor: theme.bgElevated, + borderWidth: 1, + borderColor: theme.border, + color: theme.fg, + fontFamily: fonts.body, + fontSize: 15, + }, primaryBtn: { backgroundColor: theme.accent, borderRadius: 14, paddingHorizontal: 18, paddingVertical: 10, marginLeft: 10, marginTop: 8 }, primaryBtnText: { color: '#fff', fontFamily: fonts.display, fontSize: 15 }, secondaryBtn: { borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10, marginTop: 8 },