/**
* 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 */}
);
}