feat(mobile): zakladka Swipe, pionowy feed klipow (etap 4, APK 0.4.0 / runtime 1.4)

Nowa zakladka w dolnym pasku: react-native-pager-view (pion, natywny snap), dwie
instancje createVideoPlayer (aktywna + nastepna w buforze), komorka z posterem,
podpisem tworcy i tagami, kolumna przyciskow, pasek postepu. Gesty bez reanimated:
tap = dzwiek, double-tap = ulubione z wybuchem serca i haptyka, przytrzymanie = 2x,
przesuniecie w lewo = arkusz "show less of this" (tworca / tagi / nisze / seen it,
plus blokada tworcy). Onboarding 3 tapow z top tagow, AutoSwipe (off/5/10 s),
licznik obejrzanych, nowe rozdanie.

Telemetria: SwipeTelemetry zbiera zdarzenia i wysyla paczki co 5 s / 10 zdarzen /
w tle; przy opuszczeniu strony impression z dwell, skip (<2 s) albo complete (petla
lub >=80%). Serwer z tego uczy wagi i uklada 80% nastepnych stron.

Pager to modul natywny, wiec import jest miekki (require w try/catch) i zakladka
pokazuje sie tylko od runtime 1.4; ten sam bundle leci OTA na 1.2/1.3 z wpisem
"pobierz 0.4.0" (onlyBelowRuntime), a wpis o Swipe ma nowe pole minRuntime.
Bump: app.json 0.4.0 / runtime 1.4, versionCode 14, AndroidManifest 1.4,
publish_update RUNTIME_DEFAULT 1.4, strona goon-foss.org pod 0.4.0.

Sprawdzone na emulatorze (APK 0.4.0): zakladka widoczna, onboarding zapisany na
serwerze, feed spersonalizowany 8/2, ExoPlayer dekoduje wideo i audio, swipe daje
zdarzenia complete/impression, double-tap daje like + ulubione + wagi.
This commit is contained in:
goon-foss 2026-09-15 09:24:13 +02:00
parent 538a72107e
commit 9872d5e4ca
15 changed files with 1074 additions and 15 deletions

View file

@ -93,8 +93,8 @@ android {
applicationId 'com.goon.mobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 13
versionName "0.3.0"
versionCode 14
versionName "0.4.0"
}
signingConfigs {
debug {

View file

@ -29,7 +29,7 @@
jest w repo (bare workflow), wiec zmiana app.json sama tego NIE przepisuje, robi to
dopiero prebuild. Rozjazd = APK siedzi na cudzym kanale OTA: przy 0.2.2 z wpisem 1.1
nowa aplikacja dostalaby baner migracji i kazala migrowac jeszcze raz, w kolko. -->
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="1.3"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="1.4"/>
<!-- Chromecast. `GoogleCastOptionsProvider` bez wpisu RECEIVER_APPLICATION_ID sam
wybiera domyslny odbiornik Google (`DEFAULT_MEDIA_RECEIVER_APPLICATION_ID`), wiec
CELOWO nie rejestrujemy wlasnego: rejestracja w konsoli Google wiazalaby konto

View file

@ -2,11 +2,11 @@
"expo": {
"name": "goon",
"slug": "goon",
"version": "0.3.0",
"version": "0.4.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,
"runtimeVersion": "1.3",
"runtimeVersion": "1.4",
"updates": {
"enabled": true,
"url": "https://api.goon-foss.org/expo-updates/manifest",

View file

@ -33,6 +33,7 @@
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.0",
"react-native-google-cast": "^4.9.1",
"react-native-pager-view": "6.5.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
@ -10169,6 +10170,16 @@
"react-native": "*"
}
},
"node_modules/react-native-pager-view": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.5.1.tgz",
"integrity": "sha512-YdX7bP+rPYvATMU7HzlMq9JaG3ui/+cVRbFZFGW+QshDULANFg9ECR1BA7H7JTIcO/ZgWCwF+1aVmYG5yBA9Og==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-qrcode-svg": {
"version": "6.3.21",
"resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz",

View file

@ -35,6 +35,7 @@
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.0",
"react-native-google-cast": "^4.9.1",
"react-native-pager-view": "6.5.1",
"react-native-qrcode-svg": "^6.3.21",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",

View file

@ -31,6 +31,11 @@ import type {
StudioListOut,
TagListOut,
WatchListOut,
ClipAffinityOut,
ClipEventIn,
ClipFeedPage,
ClipListOut,
ClipOnboardingOptions,
} from './types';
export class ApiError extends Error {
@ -639,4 +644,54 @@ export class GoonClient {
async listRecentWatch(limit = 10): Promise<WatchListOut> {
return this.request(`/watch/recent?limit=${limit}`);
}
// ---- Swipe (klipy) ------------------------------------------------------------------
// Feed: kursor zamiast offsetu (seed + pozycja po kluczu glownym). Pierwsza strona bez
// kursora dostaje nowy seed z serwera; kolejne ida po `next_cursor`. Kazdy podany klip
// serwer od razu pamieta jako widziany, wiec nie wraca.
async getClipFeed(params: { cursor?: string | null; seed?: string | null; limit?: number } = {}): Promise<ClipFeedPage> {
const q = new URLSearchParams();
if (params.cursor) q.set('cursor', params.cursor);
else if (params.seed) q.set('seed', params.seed);
q.set('limit', String(params.limit ?? 10));
return this.request(`/clips/feed?${q.toString()}`);
}
// Paczka zdarzen (co ~5 s / 10 zdarzen / przy zejsciu w tlo). 204, bledy polykamy
// u wolajacego: telemetria nie moze psuc ogladania.
async postClipEvents(sessionId: string, events: ClipEventIn[]): Promise<void> {
await this.request<void>('/clips/events', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, events }),
});
}
async getClipOnboardingOptions(): Promise<ClipOnboardingOptions> {
return this.request('/clips/onboarding-options');
}
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
return this.request('/clips/onboarding', { method: 'POST', body: JSON.stringify(body) });
}
async getClipAffinity(): Promise<ClipAffinityOut> {
return this.request('/clips/affinity');
}
// „Resetuj moj feed": wagi; z `seen` takze pamiec widzianych (feed od zera).
async resetClipAffinity(seen = false): Promise<void> {
await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' });
}
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
}
async addClipBlacklist(kind: 'tag' | 'niche' | 'creator', value: string): Promise<void> {
await this.request<void>(`/clips/blacklist/${kind}/${encodeURIComponent(value)}`, { method: 'POST' });
}
async removeClipBlacklist(kind: 'tag' | 'niche' | 'creator', value: string): Promise<void> {
await this.request<void>(`/clips/blacklist/${kind}/${encodeURIComponent(value)}`, { method: 'DELETE' });
}
}

View file

@ -26,9 +26,31 @@ export type ChangelogEntry = {
* wszyscy.
*/
onlyBelowRuntime?: string;
/**
* Pokaż ten wpis tylko użytkownikom z runtime CO NAJMNIEJ takim. Lustro
* `onlyBelowRuntime`: nowa funkcja z modułem natywnym (Swipe) nie ma sensu na APK,
* które jej nie ma; ci dostają zamiast tego wpis „pobierz nowe APK".
*/
minRuntime?: string;
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-15b',
date: 'September 2026',
items: [
'New Swipe tab: a vertical feed of short clips from verified creators, one per screen, playing as soon as you land on it. Double-tap to save a clip, tap to turn sound on, hold for double speed, swipe left to say what you want less of. The feed learns from what you watch and never shows the same clip twice; the refresh button deals a new hand.',
],
minRuntime: '1.4',
},
{
id: '2026-09-15a',
date: 'September 2026',
items: [
'A new Swipe section (a TikTok-style feed of short clips) is ready, but it needs version 0.4.0, which you have to download and install yourself: it uses a part of the app that cannot arrive as a silent update. Get it from goon-foss.org; it installs over the version you have and keeps your settings.',
],
onlyBelowRuntime: '1.4',
},
{
id: '2026-09-14c',
date: 'September 2026',
@ -737,7 +759,9 @@ export const NEWEST_CHANGELOG_ID = CHANGELOG.length ? CHANGELOG[0].id : null;
export function unseenEntries(lastSeenId: string | null): ChangelogEntry[] {
if (!CHANGELOG.length) return [];
const visible = CHANGELOG.filter(
(e) => !e.onlyBelowRuntime || isNewerVersion(e.onlyBelowRuntime, NATIVE_RUNTIME || '0'),
(e) =>
(!e.onlyBelowRuntime || isNewerVersion(e.onlyBelowRuntime, NATIVE_RUNTIME || '0')) &&
(!e.minRuntime || !isNewerVersion(e.minRuntime, NATIVE_RUNTIME || '0')),
);
if (!visible.length) return [];
if (!lastSeenId) return [visible[0]];

View file

@ -0,0 +1,270 @@
/**
* Jedna strona feedu Swipe: klip na caly ekran, poster pod spodem, nakladka z tworca
* i tagami, kolumna przyciskow po prawej, pasek postepu na dole.
*
* Komorka NIE tworzy playera. Player (jeden z dwoch wspoldzielonych, patrz SwipeScreen)
* przychodzi w propsach tylko do aktywnej strony; nieaktywna renderuje sam poster, zeby
* pager z kilkuset stronami nie trzymal kilkuset instancji ExoPlayera.
*
* Gesty (react-native-gesture-handler, bez reanimated, callbacki na watku JS):
* tap → dzwiek wl./wyl. (czeka, az double-tap padnie),
* double-tap → serce (ulubione) + wybuch serca + haptyka,
* przytrzymanie → 2x do puszczenia,
* przesuniecie w lewo → arkusz „nie interesuje" (pion zostaje dla pagera).
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import type { VideoPlayer } from 'expo-video';
import { VideoView } from 'expo-video';
import React from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { fonts, theme } from '../../theme';
import type { ClipFeedItem } from '../../types';
interface Props {
item: ClipFeedItem;
active: boolean;
player: VideoPlayer | null;
muted: boolean;
liked: boolean;
onToggleMute: () => void;
onLike: () => void;
onNotInterested: () => void;
onTagPress?: (tag: string) => void;
}
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress }: Props) {
const clip = item.clip;
const [progress, setProgress] = React.useState(0);
const [ready, setReady] = React.useState(false);
const heart = React.useRef(new Animated.Value(0)).current;
// Postep i gotowosc tylko dla aktywnej strony z playerem.
React.useEffect(() => {
if (!active || !player) {
setProgress(0);
setReady(false);
return;
}
const subTime = player.addListener('timeUpdate', (e) => {
const d = player.duration || clip.duration_sec || 0;
setProgress(d > 0 ? Math.min(1, e.currentTime / d) : 0);
});
const subStatus = player.addListener('statusChange', (e) => {
setReady(e.status === 'readyToPlay');
});
setReady(player.status === 'readyToPlay');
return () => {
subTime.remove();
subStatus.remove();
};
}, [active, player, clip.duration_sec]);
const burst = React.useCallback(() => {
heart.setValue(0);
Animated.sequence([
Animated.spring(heart, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 12 }),
Animated.timing(heart, { toValue: 0, duration: 350, delay: 250, useNativeDriver: true }),
]).start();
}, [heart]);
const doubleTap = React.useMemo(
() =>
Gesture.Tap()
.numberOfTaps(2)
.maxDelay(280)
.onEnd((_e, success) => {
if (!success) return;
burst();
onLike();
})
.runOnJS(true),
[burst, onLike],
);
const singleTap = React.useMemo(
() =>
Gesture.Tap()
.numberOfTaps(1)
.maxDuration(250)
.requireExternalGestureToFail(doubleTap)
.onEnd((_e, success) => {
if (success) onToggleMute();
})
.runOnJS(true),
[doubleTap, onToggleMute],
);
const longPress = React.useMemo(
() =>
Gesture.LongPress()
.minDuration(220)
.maxDistance(30)
.onStart(() => {
if (player) player.playbackRate = 2.0;
})
.onFinalize(() => {
if (player) player.playbackRate = 1.0;
})
.runOnJS(true),
[player],
);
const swipeLeft = React.useMemo(
() =>
Gesture.Pan()
.activeOffsetX([-40, 40])
.failOffsetY([-15, 15])
.onEnd((e) => {
if (e.translationX < -80) onNotInterested();
})
.runOnJS(true),
[onNotInterested],
);
const gestures = React.useMemo(
() => Gesture.Race(swipeLeft, Gesture.Exclusive(doubleTap, singleTap), longPress),
[swipeLeft, doubleTap, singleTap, longPress],
);
const poster = clip.poster_url || clip.thumb_url || undefined;
const tags = clip.tags.slice(0, 4);
return (
<GestureDetector gesture={gestures}>
<View style={styles.root}>
{poster ? (
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} />
) : null}
{active && player ? (
<VideoView
player={player}
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
nativeControls={false}
contentFit="cover"
allowsPictureInPicture={false}
/>
) : null}
{/* Wybuch serca po double-tapie */}
<Animated.View
pointerEvents="none"
style={[
styles.heartBurst,
{
opacity: heart,
transform: [{ scale: Animated.add(0.4, Animated.multiply(heart, 0.9)) }],
},
]}
>
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View>
{/* Kolumna przyciskow */}
<View style={styles.rail} pointerEvents="box-none">
<Pressable
onPress={() => {
burst();
onLike();
}}
style={styles.railBtn}
accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'}
>
<MaterialCommunityIcons name={liked ? 'heart' : 'heart-outline'} size={30} color={liked ? theme.accentText : '#fff'} />
</Pressable>
<Pressable onPress={onToggleMute} style={styles.railBtn} accessibilityLabel={muted ? 'Unmute' : 'Mute'}>
<MaterialCommunityIcons
name={clip.has_audio ? (muted ? 'volume-off' : 'volume-high') : 'volume-variant-off'}
size={28}
color={clip.has_audio ? '#fff' : '#9a9a9a'}
/>
</Pressable>
<Pressable onPress={onNotInterested} style={styles.railBtn} accessibilityLabel="Not interested">
<MaterialCommunityIcons name="thumb-down-outline" size={26} color="#fff" />
</Pressable>
</View>
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
<View style={styles.creatorRow}>
<Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</View>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
</Text>
) : null}
{tags.length ? (
<View style={styles.tagsRow}>
{tags.map((t) => (
<Pressable key={t} onPress={() => onTagPress?.(t)} style={styles.tagChip}>
<Text style={styles.tagText}>#{t}</Text>
</Pressable>
))}
</View>
) : null}
</View>
{/* Pasek postepu */}
<View style={styles.progressTrack} pointerEvents="none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
</View>
</View>
</GestureDetector>
);
}
export const ClipCell = React.memo(ClipCellBase);
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#000' },
heartBurst: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
rail: { position: 'absolute', right: 8, bottom: 96, alignItems: 'center' },
railBtn: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
backgroundColor: 'rgba(0,0,0,0.28)',
},
caption: { position: 'absolute', left: 14, right: 72, bottom: 24 },
creatorRow: { flexDirection: 'row', alignItems: 'center' },
creator: { color: '#fff', fontFamily: fonts.display, fontSize: 16, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 },
fresh: {
marginLeft: 8,
color: '#fff',
backgroundColor: theme.accent,
fontFamily: fonts.mono,
fontSize: 10,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
},
title: { color: '#eaeaea', fontFamily: fonts.body, fontSize: 14, marginTop: 4, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 },
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 },
tagChip: {
backgroundColor: 'rgba(255,255,255,0.14)',
borderRadius: 12,
paddingHorizontal: 9,
paddingVertical: 3,
marginRight: 6,
marginBottom: 6,
},
tagText: { color: '#fff', fontFamily: fonts.medium, fontSize: 12 },
progressTrack: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 3, backgroundColor: 'rgba(255,255,255,0.18)' },
progressFill: { height: 3, backgroundColor: theme.accentText },
});

View file

@ -18,7 +18,7 @@ import * as Updates from 'expo-updates';
* też nie idzie do góry, więc consistency jest zachowana.
*/
export const APP_VERSION: string =
(Constants.expoConfig?.version as string | undefined) || '0.3.0';
(Constants.expoConfig?.version as string | undefined) || '0.4.0';
/**
* Runtime zapisany w APK (`EXPO_RUNTIME_VERSION` w AndroidManifest). JEDYNY numer, po

View file

@ -0,0 +1,93 @@
/**
* Kolejka zdarzen feedu Swipe → `POST /clips/events`.
*
* Zdarzenia zbieramy w pamieci i wysylamy paczka: co 5 s, przy 10 zdarzeniach albo gdy
* aplikacja schodzi w tlo. Jeden request na kilka swipe'ow zamiast requestu na kazdy gest;
* telemetria NIE MOZE psuc ogladania, wiec bledy sieci polykamy, a nieudana paczka wraca
* do kolejki (max 200 zdarzen, starsze wypadaja).
*
* Sesja (`sessionId`) = jedno wejscie w zakladke Swipe; nowy seed (pull-to-refresh) tez
* zaczyna nowa sesje, zeby po stronie serwera dalo sie liczyc „ile klipow na posiedzenie".
*/
import { AppState, type AppStateStatus } from 'react-native';
import type { GoonClient } from '../api';
import type { ClipEventIn } from '../types';
const FLUSH_MS = 5000;
const FLUSH_AT = 10;
const MAX_QUEUE = 200;
export function newSessionId(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-6);
}
export class SwipeTelemetry {
private queue: ClipEventIn[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
private appStateSub: { remove: () => void } | null = null;
private inflight = false;
constructor(private client: GoonClient, public sessionId: string) {}
start(): void {
this.appStateSub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s !== 'active') void this.flush();
});
}
stop(): void {
this.appStateSub?.remove();
this.appStateSub = null;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
void this.flush();
}
push(ev: ClipEventIn): void {
this.queue.push(ev);
if (this.queue.length > MAX_QUEUE) this.queue.splice(0, this.queue.length - MAX_QUEUE);
if (this.queue.length >= FLUSH_AT) {
void this.flush();
return;
}
if (!this.timer) {
this.timer = setTimeout(() => {
this.timer = null;
void this.flush();
}, FLUSH_MS);
}
}
async flush(): Promise<void> {
if (this.inflight || !this.queue.length) return;
const batch = this.queue.splice(0, 100);
this.inflight = true;
try {
await this.client.postClipEvents(this.sessionId, batch);
} catch {
// Wracaja na poczatek kolejki; przy trwalej awarii MAX_QUEUE je przycina.
this.queue.unshift(...batch);
} finally {
this.inflight = false;
}
}
}
/**
* Jak zakonczylo sie ogladanie klipu, gdy uzytkownik go opuszcza.
* complete: obejrzal co najmniej raz do konca (petla) albo >= 80 % dlugosci,
* skip: uciekl przed uplywem 2 s,
* impression: wszystko pomiedzy (dwell_ms mowi serwerowi, czy to byl plus).
*/
export function leaveKind(
dwellMs: number,
loops: number,
positionSec: number,
durationSec: number | null,
): 'complete' | 'skip' | 'impression' {
if (loops >= 1) return 'complete';
if (durationSec && durationSec > 0 && positionSec >= 0.8 * durationSec) return 'complete';
if (dwellMs < 2000) return 'skip';
return 'impression';
}

View file

@ -7,6 +7,7 @@ import {
import { setBarHidden } from './lib/bottomBar';
import { BugReportFAB } from './components/BugReportFAB';
import { BottomTabsBar } from './components/BottomTabsBar';
import { SwipeScreen, swipeAvailable } from './screens/SwipeScreen';
import { GoonClient } from './api';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react';
@ -117,7 +118,7 @@ export type RootStackParamList = {
const Stack = createNativeStackNavigator<RootStackParamList>();
type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
type TopTab = 'Scenes' | 'Swipe' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
/**
* Jeden ekran-gospodarz dla pięciu zakładek. Zakładka to STAN LOKALNY, nie trasa.
@ -134,7 +135,11 @@ type TopTab = 'Scenes' | 'Movies' | 'Sites' | 'JAV' | 'Anime';
*/
function HomeScreen({ navigation }: { navigation: { setParams: (p: { tab?: string }) => void } }) {
const insets = useSafeAreaInsets();
const tabs: TopTab[] = ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
// Swipe wymaga natywnego pagera (APK od runtime 1.4). Na starszym APK zakladki nie ma,
// zamiast pokazywac ekran, ktory i tak tylko odsyla do pobrania nowej wersji.
const tabs: TopTab[] = swipeAvailable
? ['Scenes', 'Swipe', 'Movies', 'Sites', 'JAV', 'Anime']
: ['Scenes', 'Movies', 'Sites', 'JAV', 'Anime'];
const [tab, setTab] = React.useState<TopTab>('Scenes');
// BugReportFAB czyta zakładkę z route.params.tab (nazwa trasy to zawsze 'Scenes').
@ -156,7 +161,9 @@ function HomeScreen({ navigation }: { navigation: { setParams: (p: { tab?: strin
siedzi w wierszu narzedzi, darowizna i zmiana backendu w ustawieniach. Zostaje
tylko odstep na pasek statusu. */}
<View style={{ flex: 1 }} key={tab}>
{tab === 'Movies' ? (
{tab === 'Swipe' ? (
<SwipeScreen />
) : tab === 'Movies' ? (
<MoviesScreen />
) : tab === 'Sites' ? (
<SitesScreen />
@ -177,6 +184,7 @@ function HomeScreen({ navigation }: { navigation: { setParams: (p: { tab?: strin
const TAB_ICONS = {
Scenes: 'view-grid-outline',
Swipe: 'gesture-swipe-vertical',
Movies: 'movie-open-outline',
Sites: 'web',
JAV: 'star-four-points-outline',

View file

@ -0,0 +1,499 @@
/**
* Zakladka Swipe: pionowy feed klipow bez konca, w stylu TikToka.
*
* Dane: `GET /clips/feed` przez useInfiniteQuery, kursor z serwera (seed + pozycja po
* kluczu glownym), doladowanie przy 4 stronach od konca. Serwer pamieta kazdy podany
* klip jako widziany, wiec nic nie wraca; „nowe rozdanie" = nowy seed = nowa sesja.
*
* Player: DWIE instancje `createVideoPlayer` na caly feed (aktywna + nastepna w buforze),
* przelaczane po indeksie strony. Pager (`react-native-pager-view`, natywny snap) montuje
* strony, komorka dostaje playera tylko gdy jest aktywna. Modul pagera ladujemy przez
* `require` w try/catch: ten sam bundle JS leci OTA takze na starsze APK bez natywnego
* modulu, tam zakladka jest po prostu ukryta (`swipeAvailable`).
*
* Telemetria: przy opuszczeniu strony jedno z impression (z dwell) / skip / complete,
* plus like, not_interested, autoswipe; paczki co 5 s (`SwipeTelemetry`). Z tych zdarzen
* serwer uczy sie wag i uklada 80 % kolejnych stron.
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import * as Haptics from 'expo-haptics';
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import React from 'react';
import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useClient } from '../ClientContext';
import { ClipCell } from '../components/swipe/ClipCell';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { usePersistedState } from '../lib/persistedState';
import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOnboardingOptions } from '../types';
// Pager jest modulem natywnym: jest w APK od runtime 1.4. Wczesniejsze APK dostaja ten
// bundle przez OTA, wiec import musi byc miekki.
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const SWIPE_MIN_RUNTIME = '1.4';
export const swipeAvailable: boolean =
PagerViewImpl != null && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
const PAGE_SIZE = 10;
const PREFETCH_AT = 4;
const AUTOSWIPE_STEPS = [0, 5, 10] as const;
interface SwipePrefs {
soundOn: boolean;
autoSwipeSec: number;
onboarded: boolean;
}
const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false };
function sourceOf(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function SwipeScreen() {
const client = useClient();
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
const [seedKey, setSeedKey] = React.useState(0);
const [index, setIndex] = React.useState(0);
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
const pagerRef = React.useRef<any>(null);
// ---- dane ------------------------------------------------------------------------
const feed = useInfiniteQuery({
queryKey: ['clip-feed', seedKey],
queryFn: ({ pageParam }) => client.getClipFeed({ cursor: (pageParam as string | null) ?? undefined, limit: PAGE_SIZE }),
initialPageParam: null as string | null,
getNextPageParam: (last) => (last.items.length ? last.next_cursor : undefined),
enabled: prefsReady,
staleTime: Infinity,
});
const items = React.useMemo(() => {
const seen = new Set<string>();
const out: ClipFeedItem[] = [];
for (const page of feed.data?.pages ?? []) {
for (const it of page.items) {
if (!seen.has(it.clip.id) && sourceOf(it)) {
seen.add(it.clip.id);
out.push(it);
}
}
}
return out;
}, [feed.data]);
const servedTotal = feed.data?.pages.length ? feed.data.pages[feed.data.pages.length - 1].served_total : 0;
React.useEffect(() => {
if (index >= items.length - PREFETCH_AT && feed.hasNextPage && !feed.isFetchingNextPage) {
void feed.fetchNextPage();
}
}, [index, items.length, feed.hasNextPage, feed.isFetchingNextPage, feed]);
// ---- telemetria ----------------------------------------------------------------------
const telemetry = React.useRef<SwipeTelemetry | null>(null);
if (!telemetry.current) telemetry.current = new SwipeTelemetry(client, newSessionId());
React.useEffect(() => {
const t = telemetry.current!;
t.start();
return () => t.stop();
}, []);
// ---- dwa playery -----------------------------------------------------------------
const players = React.useRef<VideoPlayer[]>([]);
const loopsRef = React.useRef(0);
const activatedAtRef = React.useRef(0);
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
const prevIndexRef = React.useRef<number | null>(null);
React.useEffect(() => {
const ps = players.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
// Zamkniecie poprzedniej strony: jedno zdarzenie z dwell.
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
const dwell = Date.now() - activatedAtRef.current;
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
telemetry.current?.push({
clip_id: items[prev].clip.id,
kind: leaveKind(dwell, loopsRef.current, pos, items[prev].clip.duration_sec),
dwell_ms: dwell,
slot: items[prev].slot,
position: prev,
});
try {
prevPlayer.pause();
} catch {
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, sourceOf(item));
active.muted = !prefs.soundOn;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
const next = items[index + 1];
if (next) {
ensureSource(standby, sourceOf(next));
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, items, ensureSource, prefs.soundOn]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !prefs.soundOn;
}, [prefs.soundOn, activePlayer]);
// AutoSwipe: po N sekundach na stronie przechodzimy dalej.
React.useEffect(() => {
if (!prefs.autoSwipeSec || !items[index]) return;
const t = setTimeout(() => {
if (index + 1 < items.length) {
telemetry.current?.push({ clip_id: items[index].clip.id, kind: 'autoswipe', slot: items[index].slot, position: index });
pagerRef.current?.setPage(index + 1);
}
}, prefs.autoSwipeSec * 1000);
return () => clearTimeout(t);
}, [prefs.autoSwipeSec, index, items]);
// ---- akcje ---------------------------------------------------------------------------
const toggleMute = React.useCallback(() => {
setPrefs((p) => ({ ...p, soundOn: !p.soundOn }));
}, [setPrefs]);
const likeItem = React.useCallback(
(item: ClipFeedItem, position: number) => {
const isLiked = liked.has(item.clip.id);
setLiked((s) => {
const n = new Set(s);
if (isLiked) n.delete(item.clip.id);
else n.add(item.clip.id);
return n;
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
telemetry.current?.push({ clip_id: item.clip.id, kind: isLiked ? 'unlike' : 'like', slot: item.slot, position });
},
[liked],
);
const notInterested = React.useCallback(
(item: ClipFeedItem, reason: string, position: number) => {
telemetry.current?.push({ clip_id: item.clip.id, kind: 'not_interested', reason, slot: item.slot, position });
void telemetry.current?.flush();
setSheetItem(null);
if (position + 1 < items.length) pagerRef.current?.setPage(position + 1);
},
[items.length],
);
const newDeal = React.useCallback(() => {
telemetry.current?.stop();
telemetry.current = new SwipeTelemetry(client, newSessionId());
telemetry.current.start();
prevIndexRef.current = null;
setIndex(0);
setSeedKey((k) => k + 1);
}, [client]);
const cycleAutoSwipe = React.useCallback(() => {
setPrefs((p) => {
const i = AUTOSWIPE_STEPS.indexOf(p.autoSwipeSec as (typeof AUTOSWIPE_STEPS)[number]);
return { ...p, autoSwipeSec: AUTOSWIPE_STEPS[(i + 1) % AUTOSWIPE_STEPS.length] };
});
}, [setPrefs]);
// ---- onboarding ------------------------------------------------------------------------
const options = useQuery({
queryKey: ['clip-onboarding-options'],
queryFn: () => client.getClipOnboardingOptions(),
enabled: prefsReady && !prefs.onboarded,
staleTime: 10 * 60 * 1000,
});
const finishOnboarding = React.useCallback(
async (tags: string[], niches: string[]) => {
if (tags.length || niches.length) {
try {
await client.postClipOnboarding({ tags, niches });
} catch {
// wagi doucza sie z ogladania, onboarding to tylko start
}
}
setPrefs((p) => ({ ...p, onboarded: true }));
newDeal();
},
[client, setPrefs, newDeal],
);
// ---- render --------------------------------------------------------------------------
if (!swipeAvailable || !PagerViewImpl) {
return (
<View style={styles.center}>
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
<Text style={styles.centerTitle}>Swipe needs the 0.4.0 app</Text>
<Text style={styles.centerText}>This section uses a part of the app that cannot arrive as a silent update. Download 0.4.0 from goon-foss.org, it installs over this one.</Text>
</View>
);
}
const PagerView = PagerViewImpl;
const current = items[index];
return (
<View style={styles.root}>
{items.length ? (
<PagerView
ref={pagerRef}
style={styles.pager}
orientation="vertical"
initialPage={0}
offscreenPageLimit={1}
overdrag
onPageSelected={(e: { nativeEvent: { position: number } }) => setIndex(e.nativeEvent.position)}
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>
<ClipCell
item={it}
active={i === index}
player={i === index ? activePlayer : null}
muted={!prefs.soundOn}
liked={liked.has(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={toggleMute}
onLike={() => likeItem(it, i)}
onNotInterested={() => setSheetItem(it)}
/>
</View>
))}
</PagerView>
) : feed.isLoading ? (
<View style={styles.center}>
<ActivityIndicator color={theme.accentText} />
</View>
) : (
<View style={styles.center}>
<Text style={styles.centerTitle}>Nothing new right now</Text>
<Text style={styles.centerText}>You have seen everything we have for now. Pull a new deal or come back later.</Text>
<Pressable onPress={newDeal} style={styles.primaryBtn}>
<Text style={styles.primaryBtnText}>New deal</Text>
</Pressable>
</View>
)}
{/* Gorny pasek: licznik + sterowanie */}
<View style={styles.topBar} pointerEvents="box-none">
<View style={styles.pill}>
<Text style={styles.pillText}>{servedTotal} seen</Text>
</View>
<View style={{ flex: 1 }} />
<Pressable onPress={cycleAutoSwipe} style={styles.topBtn} accessibilityLabel="AutoSwipe">
<MaterialCommunityIcons name={prefs.autoSwipeSec ? 'play-speed' : 'pause-circle-outline'} size={22} color="#fff" />
{prefs.autoSwipeSec ? <Text style={styles.topBtnLabel}>{prefs.autoSwipeSec}s</Text> : null}
</Pressable>
<Pressable onPress={toggleMute} style={styles.topBtn} accessibilityLabel="Sound">
<MaterialCommunityIcons name={prefs.soundOn ? 'volume-high' : 'volume-off'} size={22} color="#fff" />
</Pressable>
<Pressable onPress={newDeal} style={styles.topBtn} accessibilityLabel="New deal">
<MaterialCommunityIcons name="refresh" size={22} color="#fff" />
</Pressable>
</View>
{/* Arkusz „nie interesuje" */}
<Modal visible={!!sheetItem} transparent animationType="slide" onRequestClose={() => setSheetItem(null)}>
<Pressable style={styles.sheetBackdrop} onPress={() => setSheetItem(null)} />
{sheetItem ? (
<View style={styles.sheet}>
<Text style={styles.sheetTitle}>Show less of this</Text>
<View style={styles.chipsWrap}>
{sheetItem.clip.creator ? (
<Chip label={`@${sheetItem.clip.creator}`} onPress={() => notInterested(sheetItem, `creator:${sheetItem.clip.creator}`, index)} />
) : null}
{sheetItem.clip.tags.slice(0, 6).map((t) => (
<Chip key={t} label={`#${t}`} onPress={() => notInterested(sheetItem, `tag:${t}`, index)} />
))}
{sheetItem.clip.niches.slice(0, 2).map((n) => (
<Chip key={n} label={n} onPress={() => notInterested(sheetItem, `niche:${n}`, index)} />
))}
<Chip label="Seen it already" onPress={() => notInterested(sheetItem, 'seen_it', index)} />
</View>
{sheetItem.clip.creator ? (
<Pressable
style={styles.blockBtn}
onPress={() => {
const c = sheetItem.clip.creator!;
client.addClipBlacklist('creator', c).catch(() => {});
notInterested(sheetItem, `creator:${c}`, index);
}}
>
<MaterialCommunityIcons name="account-cancel-outline" size={18} color={theme.accentText} />
<Text style={styles.blockText}>Never show @{sheetItem.clip.creator} again</Text>
</Pressable>
) : null}
</View>
) : null}
</Modal>
{/* Onboarding 3 tapow */}
<OnboardingModal
visible={prefsReady && !prefs.onboarded && !!options.data}
options={options.data}
onDone={finishOnboarding}
/>
</View>
);
}
function Chip({ label, onPress, selected }: { label: string; onPress: () => void; selected?: boolean }) {
return (
<Pressable onPress={onPress} style={[styles.chip, selected && styles.chipSelected]}>
<Text style={[styles.chipText, selected && styles.chipTextSelected]} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
function OnboardingModal({
visible,
options,
onDone,
}: {
visible: boolean;
options: ClipOnboardingOptions | undefined;
onDone: (tags: string[], niches: string[]) => void;
}) {
const [picked, setPicked] = React.useState<Set<string>>(new Set());
const tags = (options?.tags ?? []).slice(0, 24);
const toggle = (t: string) =>
setPicked((s) => {
const n = new Set(s);
if (n.has(t)) n.delete(t);
else n.add(t);
return n;
});
return (
<Modal visible={visible} transparent animationType="fade">
<View style={styles.onboardBackdrop}>
<View style={styles.onboardCard}>
<Text style={styles.sheetTitle}>What do you want to see?</Text>
<Text style={styles.centerText}>Pick three or more. The feed learns from what you watch anyway, this only gives it a head start.</Text>
<ScrollView style={{ maxHeight: 300 }} contentContainerStyle={styles.chipsWrap}>
{tags.map((t) => (
<Chip key={t.value} label={`#${t.value}`} selected={picked.has(t.value)} onPress={() => toggle(t.value)} />
))}
</ScrollView>
<View style={styles.onboardRow}>
<Pressable onPress={() => onDone([], [])} style={styles.secondaryBtn}>
<Text style={styles.secondaryBtnText}>Skip</Text>
</Pressable>
<Pressable
onPress={() => onDone([...picked], [])}
disabled={picked.size < 3}
style={[styles.primaryBtn, picked.size < 3 && { opacity: 0.4 }]}
>
<Text style={styles.primaryBtnText}>Start{picked.size ? ` (${picked.size})` : ''}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#000' },
pager: { flex: 1 },
page: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg },
centerTitle: { color: theme.fg, fontFamily: fonts.display, fontSize: 18, marginTop: 12, textAlign: 'center' },
centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 },
topBar: { position: 'absolute', top: 6, left: 10, right: 6, flexDirection: 'row', alignItems: 'center' },
pill: { backgroundColor: 'rgba(0,0,0,0.35)', borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4 },
pillText: { color: '#fff', fontFamily: fonts.mono, fontSize: 12 },
topBtn: { flexDirection: 'row', alignItems: 'center', padding: 8, marginLeft: 2, borderRadius: 18, backgroundColor: 'rgba(0,0,0,0.28)' },
topBtnLabel: { color: '#fff', fontFamily: fonts.mono, fontSize: 11, marginLeft: 3 },
sheetBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' },
sheet: { backgroundColor: theme.card, padding: 18, paddingBottom: 28, borderTopLeftRadius: 18, borderTopRightRadius: 18 },
sheetTitle: { color: theme.fg, fontFamily: fonts.display, fontSize: 17, marginBottom: 10 },
chipsWrap: { flexDirection: 'row', flexWrap: 'wrap' },
chip: { borderRadius: 14, paddingHorizontal: 12, paddingVertical: 7, marginRight: 8, marginBottom: 8, backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
chipSelected: { backgroundColor: theme.accent, borderColor: theme.accent },
chipText: { color: theme.fg, fontFamily: fonts.medium, fontSize: 13 },
chipTextSelected: { color: '#fff' },
blockBtn: { flexDirection: 'row', alignItems: 'center', marginTop: 8, paddingVertical: 8 },
blockText: { color: theme.accentText, fontFamily: fonts.medium, fontSize: 14, marginLeft: 8 },
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 },
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 },
secondaryBtnText: { color: theme.muted, fontFamily: fonts.medium, fontSize: 15 },
});

View file

@ -434,3 +434,90 @@ export interface WatchEntry {
export interface WatchListOut {
items: WatchEntry[];
}
// ---- Swipe (klipy) --------------------------------------------------------------------
// Klip to krotkie pionowe wideo tworcy (RedGifs), osobny wertykal od scen. Adresy plikow
// sa publiczne (media.redgifs.com), graja direct bez proxy i bez Referera.
export interface ClipOut {
id: string;
source: string;
source_id: string;
title: string | null;
creator: string | null;
creator_verified: boolean;
duration_sec: number | null;
width: number | null;
height: number | null;
has_audio: boolean;
content_type: string | null;
niches: string[];
tags: string[];
hd_url: string | null;
sd_url: string | null;
poster_url: string | null;
thumb_url: string | null;
likes: number;
views: number;
published_at: string | null;
is_favorite?: boolean;
}
export interface ClipFeedItem {
clip: ClipOut;
/** 'affinity' | 'explore': z ktorego koszyka przyszedl klip (do telemetrii). */
slot: string;
is_fresh: boolean;
}
export interface ClipFeedPage {
items: ClipFeedItem[];
next_cursor: string;
seed: string;
wrapped: boolean;
served_total: number;
personalized: boolean;
}
export type ClipEventKind =
| 'impression'
| 'skip'
| 'complete'
| 'like'
| 'unlike'
| 'not_interested'
| 'autoswipe'
| 'open';
export interface ClipEventIn {
clip_id: string;
kind: ClipEventKind;
dwell_ms?: number;
/** Powod „nie interesuje": `tag:<slug>` | `niche:<id>` | `creator:<name>` | `seen_it`. */
reason?: string;
slot?: string;
position?: number;
}
export interface ClipOnboardingOption {
value: string;
count: number;
}
export interface ClipOnboardingOptions {
tags: ClipOnboardingOption[];
niches: ClipOnboardingOption[];
}
export interface ClipAffinityOut {
tags: Record<string, number>;
niches: Record<string, number>;
creators: Record<string, number>;
events_seen: number;
}
export interface ClipListOut {
items: ClipOut[];
limit: number;
offset: number;
has_more: boolean;
}

View file

@ -40,7 +40,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MOBILE = ROOT / "mobile"
DIST = MOBILE / "dist"
RUNTIME_DEFAULT = "1.2" # == EXPO_RUNTIME_VERSION w AndroidManifest + app.json runtimeVersion.
RUNTIME_DEFAULT = "1.4" # == EXPO_RUNTIME_VERSION w AndroidManifest + app.json runtimeVersion.
# Historia kanalow: 1.0 -> 1.1 (2026-06) -> 1.2 (2026-08-11, APK 0.2.2 z expo-keep-awake).
# 1.1 wciaz ZYJE: siedza tam instalacje sprzed 0.2.2, ktore nie moga sie zaktualizowac in-place
# (stary klucz podpisujacy przepadl) i dostaly baner migracji. Poprawki dla nich publikuj

View file

@ -95,7 +95,7 @@
</p>
<div class="flex flex-wrap gap-3">
<a href="/goon-v0.3.0.apk"
<a href="/goon-v0.4.0.apk"
class="px-6 py-4 rounded-xl bg-accent text-white font-bold hover:bg-accentDeep transition glow">
Download APK
</a>
@ -240,6 +240,17 @@
</p>
<div class="space-y-6">
<div class="bg-card border border-border rounded-2xl p-6">
<div class="font-mono text-xs text-accent mb-2">September 2026 &middot; 0.4.0</div>
<h3 class="text-lg font-bold mb-2">Swipe</h3>
<p class="text-sm text-gray-400 leading-relaxed">
A new tab with a vertical feed of short clips from verified creators, one per
screen, playing the moment you land on it. Double-tap to save, tap for sound,
hold for double speed, swipe left to say what you want less of. The feed learns
from what you watch and never repeats a clip. Needs the 0.4.0 APK; it installs
over the version you have.
</p>
</div>
<div class="bg-card border border-border rounded-2xl p-6">
<div class="font-mono text-xs text-accent mb-2">August 2026</div>
<h3 class="text-lg font-bold mb-2">Fewer dead links</h3>
@ -301,7 +312,7 @@
<div class="bg-card border-2 border-accent rounded-2xl p-5 mb-6">
<p class="text-sm font-bold text-accent mb-2">Already have Goon installed? Read this first.</p>
<p class="text-sm text-gray-300 leading-relaxed">
<strong class="text-gray-100">On 0.2.2 or newer:</strong> nothing to do. 0.3.0 installs
<strong class="text-gray-100">On 0.2.2 or newer:</strong> nothing to do. 0.4.0 installs
straight over it and keeps your favourites, saved searches and watch progress.
<br><br>
<strong class="text-gray-100">On anything older than 0.2.2:</strong> that build was
@ -316,7 +327,7 @@
<li class="bg-card border border-border rounded-2xl p-5 flex gap-4">
<span class="text-accent font-extrabold text-xl">1</span>
<span class="text-sm text-gray-300 leading-relaxed">
<a href="/goon-v0.3.0.apk" class="text-accent font-bold hover:underline">Download the APK</a>
<a href="/goon-v0.4.0.apk" class="text-accent font-bold hover:underline">Download the APK</a>
and open it. Allow "install from unknown sources" for your browser if Android asks.
</span>
</li>
@ -393,7 +404,7 @@
</p>
</div>
<div class="flex flex-col gap-1 text-right">
<a href="/goon-v0.3.0.apk" class="hover:text-accent transition">Download APK</a>
<a href="/goon-v0.4.0.apk" class="hover:text-accent transition">Download APK</a>
<a href="https://git.goon-foss.org/goon-foss/goon" class="hover:text-accent transition">Source code</a>
<a href="#donate" class="hover:text-accent transition">Donate</a>
</div>