Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
This commit is contained in:
524
apps/mobile/app/session/[id].tsx
Normal file
524
apps/mobile/app/session/[id].tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Session detail screen
|
||||
* Shows comprehensive information about a specific session/stream
|
||||
* Query keys include selectedServerId for proper cache isolation per media server
|
||||
*/
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { View, Text, ScrollView, Pressable, ActivityIndicator, Image, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
User,
|
||||
Server,
|
||||
MapPin,
|
||||
Smartphone,
|
||||
Clock,
|
||||
Gauge,
|
||||
Tv,
|
||||
Film,
|
||||
Music,
|
||||
Zap,
|
||||
Globe,
|
||||
Wifi,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import { api, getServerUrl } from '@/lib/api';
|
||||
import { useMediaServer } from '@/providers/MediaServerProvider';
|
||||
import { colors } from '@/lib/theme';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { SessionWithDetails, SessionState, MediaType } from '@tracearr/shared';
|
||||
|
||||
// Safe date parsing helper - handles string dates from API
|
||||
function safeParseDate(date: Date | string | null | undefined): Date | null {
|
||||
if (!date) return null;
|
||||
const parsed = new Date(date);
|
||||
return isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
// Safe format date helper
|
||||
function safeFormatDate(date: Date | string | null | undefined, formatStr: string): string {
|
||||
const parsed = safeParseDate(date);
|
||||
if (!parsed) return 'Unknown';
|
||||
return format(parsed, formatStr);
|
||||
}
|
||||
|
||||
// Get state icon, color, and badge variant
|
||||
function getStateInfo(state: SessionState, watched?: boolean): {
|
||||
icon: typeof Play;
|
||||
color: string;
|
||||
label: string;
|
||||
variant: 'success' | 'warning' | 'secondary';
|
||||
} {
|
||||
// Show "Watched" for completed sessions where user watched 80%+
|
||||
if (watched && state === 'stopped') {
|
||||
return { icon: Play, color: colors.success, label: 'Watched', variant: 'success' };
|
||||
}
|
||||
switch (state) {
|
||||
case 'playing':
|
||||
return { icon: Play, color: colors.success, label: 'Playing', variant: 'success' };
|
||||
case 'paused':
|
||||
return { icon: Pause, color: colors.warning, label: 'Paused', variant: 'warning' };
|
||||
case 'stopped':
|
||||
return { icon: Square, color: colors.text.secondary.dark, label: 'Stopped', variant: 'secondary' };
|
||||
default:
|
||||
return { icon: Square, color: colors.text.secondary.dark, label: 'Unknown', variant: 'secondary' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get media type icon
|
||||
function getMediaIcon(mediaType: MediaType): typeof Film {
|
||||
switch (mediaType) {
|
||||
case 'movie':
|
||||
return Film;
|
||||
case 'episode':
|
||||
return Tv;
|
||||
case 'track':
|
||||
return Music;
|
||||
default:
|
||||
return Film;
|
||||
}
|
||||
}
|
||||
|
||||
// Format duration
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return '-';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
// Format bitrate
|
||||
function formatBitrate(bitrate: number | null): string {
|
||||
if (bitrate === null) return '-';
|
||||
if (bitrate >= 1000) {
|
||||
return `${(bitrate / 1000).toFixed(1)} Mbps`;
|
||||
}
|
||||
return `${bitrate} Kbps`;
|
||||
}
|
||||
|
||||
// Info card component
|
||||
function InfoCard({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View className="bg-card rounded-xl p-4 mb-4">
|
||||
<Text className="text-muted-foreground text-sm font-medium mb-3">{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Info row component
|
||||
function InfoRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
valueColor,
|
||||
}: {
|
||||
icon: typeof Play;
|
||||
label: string;
|
||||
value: string;
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-row items-center py-2 border-b border-border last:border-b-0">
|
||||
<Icon size={18} color={colors.text.secondary.dark} />
|
||||
<Text className="text-muted-foreground text-sm ml-3 flex-1">{label}</Text>
|
||||
<Text
|
||||
className="text-sm font-medium"
|
||||
style={{ color: valueColor || colors.text.primary.dark }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Progress bar component
|
||||
function ProgressBar({
|
||||
progress,
|
||||
total,
|
||||
}: {
|
||||
progress: number | null;
|
||||
total: number | null;
|
||||
}) {
|
||||
if (progress === null || total === null || total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentage = Math.min((progress / total) * 100, 100);
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(progress)}</Text>
|
||||
<Text style={{ color: colors.text.secondary.dark, fontSize: 12 }}>{formatDuration(total)}</Text>
|
||||
</View>
|
||||
<View style={{ backgroundColor: '#27272a', height: 8, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.cyan.core,
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
width: `${percentage}%`
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={{ color: '#71717a', fontSize: 12, textAlign: 'center', marginTop: 4 }}>
|
||||
{percentage.toFixed(1)}% watched
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedServerId } = useMediaServer();
|
||||
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||
|
||||
// Load server URL for image paths
|
||||
useEffect(() => {
|
||||
void getServerUrl().then(setServerUrl);
|
||||
}, []);
|
||||
|
||||
// Terminate session mutation
|
||||
const terminateMutation = useMutation({
|
||||
mutationFn: ({ sessionId, reason }: { sessionId: string; reason?: string }) =>
|
||||
api.sessions.terminate(sessionId, reason),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||
Alert.alert('Stream Terminated', 'The playback session has been stopped.');
|
||||
router.back();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
Alert.alert('Failed to Terminate', error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Handle terminate button press
|
||||
const handleTerminate = () => {
|
||||
Alert.prompt(
|
||||
'Terminate Stream',
|
||||
'Enter an optional message to show the user (leave empty to skip):',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Terminate',
|
||||
style: 'destructive',
|
||||
onPress: (reason: string | undefined) => {
|
||||
terminateMutation.mutate({ sessionId: id, reason: reason?.trim() || undefined });
|
||||
},
|
||||
},
|
||||
],
|
||||
'plain-text',
|
||||
'',
|
||||
'default'
|
||||
);
|
||||
};
|
||||
|
||||
const {
|
||||
data: session,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<SessionWithDetails>({
|
||||
queryKey: ['session', id, selectedServerId],
|
||||
queryFn: async () => {
|
||||
console.log('[SessionDetail] Fetching session:', id);
|
||||
try {
|
||||
const result = await api.sessions.get(id);
|
||||
console.log('[SessionDetail] Received session data:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[SessionDetail] API error:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('[SessionDetail] State:', { id, isLoading, hasError: !!error, hasSession: !!session });
|
||||
if (error) {
|
||||
console.error('[SessionDetail] Query error:', error);
|
||||
}
|
||||
if (session) {
|
||||
console.log('[SessionDetail] Session fields:', {
|
||||
id: session.id,
|
||||
username: session.username,
|
||||
mediaTitle: session.mediaTitle,
|
||||
state: session.state,
|
||||
});
|
||||
}
|
||||
}, [id, isLoading, error, session]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center' }}
|
||||
edges={['left', 'right', 'bottom']}
|
||||
>
|
||||
<ActivityIndicator size="large" color={colors.cyan.core} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{ flex: 1, backgroundColor: colors.background.dark, justifyContent: 'center', alignItems: 'center', padding: 16 }}
|
||||
edges={['left', 'right', 'bottom']}
|
||||
>
|
||||
<Text style={{ color: '#f87171', textAlign: 'center' }}>
|
||||
{error instanceof Error ? error.message : 'Failed to load session'}
|
||||
</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const stateInfo = getStateInfo(session.state, session.watched);
|
||||
const MediaIcon = getMediaIcon(session.mediaType);
|
||||
|
||||
// Format media title with episode info
|
||||
const getMediaTitle = (): string => {
|
||||
if (session.mediaType === 'episode' && session.grandparentTitle) {
|
||||
const episodeInfo = session.seasonNumber && session.episodeNumber
|
||||
? `S${session.seasonNumber}E${session.episodeNumber}`
|
||||
: '';
|
||||
return `${session.grandparentTitle}${episodeInfo ? ` • ${episodeInfo}` : ''}`;
|
||||
}
|
||||
return session.mediaTitle;
|
||||
};
|
||||
|
||||
const getSubtitle = (): string => {
|
||||
if (session.mediaType === 'episode') {
|
||||
return session.mediaTitle; // Episode title
|
||||
}
|
||||
if (session.year) {
|
||||
return String(session.year);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Get location string
|
||||
const getLocation = (): string => {
|
||||
const parts = [session.geoCity, session.geoRegion, session.geoCountry].filter(Boolean);
|
||||
return parts.join(', ') || 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background.dark }} edges={['left', 'right', 'bottom']}>
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }}>
|
||||
{/* Media Header */}
|
||||
<View className="bg-card rounded-xl p-4 mb-4">
|
||||
{/* Terminate button - top right */}
|
||||
<View className="absolute top-2 right-2 z-10">
|
||||
<Pressable
|
||||
onPress={handleTerminate}
|
||||
disabled={terminateMutation.isPending}
|
||||
className="w-8 h-8 rounded-full bg-destructive/10 items-center justify-center active:opacity-70"
|
||||
style={{ opacity: terminateMutation.isPending ? 0.5 : 1 }}
|
||||
>
|
||||
<X size={18} color="#ef4444" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-start">
|
||||
{/* Poster/Thumbnail */}
|
||||
<View className="w-20 h-28 bg-surface rounded-lg mr-4 overflow-hidden">
|
||||
{session.thumbPath && serverUrl ? (
|
||||
<Image
|
||||
source={{ uri: `${serverUrl}/api/v1/images/proxy?server=${session.serverId}&url=${encodeURIComponent(session.thumbPath)}&width=160&height=224` }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full justify-center items-center">
|
||||
<MediaIcon size={32} color={colors.text.secondary.dark} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Media Info */}
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center mb-2">
|
||||
<Badge variant={stateInfo.variant}>
|
||||
{stateInfo.label}
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<Text className="text-white text-lg font-semibold" numberOfLines={2}>
|
||||
{getMediaTitle()}
|
||||
</Text>
|
||||
|
||||
{getSubtitle() ? (
|
||||
<Text className="text-muted-foreground text-sm mt-1" numberOfLines={1}>
|
||||
{getSubtitle()}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View className="flex-row items-center mt-2">
|
||||
<MediaIcon size={14} color={colors.text.secondary.dark} />
|
||||
<Text className="text-muted-foreground text-xs ml-1 capitalize">
|
||||
{session.mediaType}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress bar */}
|
||||
<ProgressBar progress={session.progressMs} total={session.totalDurationMs} />
|
||||
</View>
|
||||
|
||||
{/* User Card - Tappable */}
|
||||
<Pressable
|
||||
onPress={() => router.push(`/user/${session.serverUserId}` as never)}
|
||||
className="bg-card rounded-xl p-4 mb-4 active:opacity-70"
|
||||
>
|
||||
<Text className="text-muted-foreground text-sm font-medium mb-3">User</Text>
|
||||
<View className="flex-row items-center">
|
||||
<View className="w-12 h-12 rounded-full bg-surface overflow-hidden">
|
||||
{session.userThumb ? (
|
||||
<Image
|
||||
source={{ uri: session.userThumb }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full justify-center items-center">
|
||||
<User size={24} color={colors.text.secondary.dark} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1 ml-3">
|
||||
<Text className="text-foreground text-base font-semibold">
|
||||
{session.username}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm">Tap to view profile</Text>
|
||||
</View>
|
||||
<Text className="text-primary text-sm">→</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Server Info */}
|
||||
<InfoCard title="Server">
|
||||
<View className="flex-row items-center">
|
||||
<Server size={20} color={colors.text.secondary.dark} />
|
||||
<View className="flex-1 ml-3">
|
||||
<Text className="text-foreground text-base font-medium">
|
||||
{session.serverName}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-sm capitalize">
|
||||
{session.serverType}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</InfoCard>
|
||||
|
||||
{/* Timing Info */}
|
||||
<InfoCard title="Timing">
|
||||
<InfoRow
|
||||
icon={Clock}
|
||||
label="Started"
|
||||
value={safeFormatDate(session.startedAt, 'MMM d, yyyy h:mm a')}
|
||||
/>
|
||||
{session.stoppedAt && (
|
||||
<InfoRow
|
||||
icon={Square}
|
||||
label="Stopped"
|
||||
value={safeFormatDate(session.stoppedAt, 'MMM d, yyyy h:mm a')}
|
||||
/>
|
||||
)}
|
||||
<InfoRow
|
||||
icon={Play}
|
||||
label="Watch Time"
|
||||
value={formatDuration(session.durationMs)}
|
||||
/>
|
||||
{(session.pausedDurationMs ?? 0) > 0 && (
|
||||
<InfoRow
|
||||
icon={Pause}
|
||||
label="Paused Time"
|
||||
value={formatDuration(session.pausedDurationMs)}
|
||||
/>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Location Info */}
|
||||
<InfoCard title="Location">
|
||||
<InfoRow icon={Globe} label="IP Address" value={session.ipAddress || 'Unknown'} />
|
||||
<InfoRow icon={MapPin} label="Location" value={getLocation()} />
|
||||
{session.geoLat && session.geoLon && (
|
||||
<InfoRow
|
||||
icon={MapPin}
|
||||
label="Coordinates"
|
||||
value={`${session.geoLat.toFixed(4)}, ${session.geoLon.toFixed(4)}`}
|
||||
/>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Device Info */}
|
||||
<InfoCard title="Device">
|
||||
<InfoRow
|
||||
icon={Smartphone}
|
||||
label="Player"
|
||||
value={session.playerName || 'Unknown'}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Tv}
|
||||
label="Device"
|
||||
value={session.device || 'Unknown'}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Wifi}
|
||||
label="Platform"
|
||||
value={session.platform || 'Unknown'}
|
||||
/>
|
||||
{session.product && (
|
||||
<InfoRow icon={Smartphone} label="Product" value={session.product} />
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Quality Info */}
|
||||
<InfoCard title="Quality">
|
||||
<InfoRow
|
||||
icon={Gauge}
|
||||
label="Quality"
|
||||
value={session.quality || 'Unknown'}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={Zap}
|
||||
label="Transcode"
|
||||
value={session.isTranscode ? 'Yes' : 'Direct Play'}
|
||||
valueColor={session.isTranscode ? colors.warning : colors.success}
|
||||
/>
|
||||
{session.bitrate && (
|
||||
<InfoRow icon={Gauge} label="Bitrate" value={formatBitrate(session.bitrate)} />
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{/* Bottom padding */}
|
||||
<View className="h-8" />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
43
apps/mobile/app/session/_layout.tsx
Normal file
43
apps/mobile/app/session/_layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Session detail stack navigator layout
|
||||
* Provides navigation for session detail screens
|
||||
*/
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { Pressable } from 'react-native';
|
||||
import { ChevronLeft } from 'lucide-react-native';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
export default function SessionLayout() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background.dark,
|
||||
},
|
||||
headerTintColor: colors.text.primary.dark,
|
||||
headerTitleStyle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerBackTitle: 'Back',
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => router.back()} hitSlop={8}>
|
||||
<ChevronLeft size={28} color={colors.text.primary.dark} />
|
||||
</Pressable>
|
||||
),
|
||||
contentStyle: {
|
||||
backgroundColor: colors.background.dark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="[id]"
|
||||
options={{
|
||||
title: 'Session',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user