/** * 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 ( {title} {children} ); } // Info row component function InfoRow({ icon: Icon, label, value, valueColor, }: { icon: typeof Play; label: string; value: string; valueColor?: string; }) { return ( {label} {value} ); } // 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 ( {formatDuration(progress)} {formatDuration(total)} {percentage.toFixed(1)}% watched ); } export default function SessionDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const queryClient = useQueryClient(); const { selectedServerId } = useMediaServer(); const [serverUrl, setServerUrl] = useState(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({ 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 ( ); } if (error || !session) { return ( {error instanceof Error ? error.message : 'Failed to load session'} ); } 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 ( {/* Media Header */} {/* Terminate button - top right */} {/* Poster/Thumbnail */} {session.thumbPath && serverUrl ? ( ) : ( )} {/* Media Info */} {stateInfo.label} {getMediaTitle()} {getSubtitle() ? ( {getSubtitle()} ) : null} {session.mediaType} {/* Progress bar */} {/* User Card - Tappable */} router.push(`/user/${session.serverUserId}` as never)} className="bg-card rounded-xl p-4 mb-4 active:opacity-70" > User {session.userThumb ? ( ) : ( )} {session.username} Tap to view profile {/* Server Info */} {session.serverName} {session.serverType} {/* Timing Info */} {session.stoppedAt && ( )} {(session.pausedDurationMs ?? 0) > 0 && ( )} {/* Location Info */} {session.geoLat && session.geoLon && ( )} {/* Device Info */} {session.product && ( )} {/* Quality Info */} {session.bitrate && ( )} {/* Bottom padding */} ); }