diff --git a/examples/player-status-integration.ts b/examples/player-status-integration.ts new file mode 100644 index 0000000..c364705 --- /dev/null +++ b/examples/player-status-integration.ts @@ -0,0 +1,196 @@ +// Example frontend implementation for the player status API + +/** + * Function to send player status to the backend + */ +export async function sendPlayerStatus({ + userId, + roomCode, + isHost, + content, + player +}: { + userId: string; + roomCode: string; + isHost: boolean; + content: { + title: string; + type: string; + }; + player: { + isPlaying: boolean; + isPaused: boolean; + isLoading: boolean; + hasPlayedOnce: boolean; + time: number; + duration: number; + volume: number; + playbackRate: number; + buffered: number; + }; +}) { + try { + const response = await fetch('/api/player/status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId, + roomCode, + isHost, + content, + player, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send player status: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Successfully sent player status update', data); + return data; + } catch (error) { + console.error('Error sending player status:', error); + throw error; + } +} + +/** + * Function to get player status for a specific user in a room + */ +export async function getPlayerStatus(userId: string, roomCode: string) { + try { + const response = await fetch(`/api/player/status?userId=${userId}&roomCode=${roomCode}`); + + if (!response.ok) { + throw new Error(`Failed to get player status: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Retrieved player status data:', data); + return data; + } catch (error) { + console.error('Error getting player status:', error); + throw error; + } +} + +/** + * Function to get status for all users in a room + */ +export async function getRoomStatuses(roomCode: string) { + try { + const response = await fetch(`/api/player/status?roomCode=${roomCode}`); + + if (!response.ok) { + throw new Error(`Failed to get room statuses: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Retrieved room statuses data:', data); + return data; + } catch (error) { + console.error('Error getting room statuses:', error); + throw error; + } +} + +/** + * Example implementation for updating WebhookReporter to use the API + */ +export function ModifiedWebhookReporter() { + // Example replacing the Discord webhook code + + /* + useEffect(() => { + // Skip if watch party is not enabled or no status + if (!watchPartyEnabled || !latestStatus || !latestStatus.hasPlayedOnce) return; + + const now = Date.now(); + + // Create a state fingerprint to detect meaningful changes + const stateFingerprint = JSON.stringify({ + isPlaying: latestStatus.isPlaying, + isPaused: latestStatus.isPaused, + isLoading: latestStatus.isLoading, + time: Math.floor(latestStatus.time / 5) * 5, // Round to nearest 5 seconds + volume: Math.round(latestStatus.volume * 100), + playbackRate: latestStatus.playbackRate, + }); + + // Check if state has changed meaningfully AND + // it's been at least 5 seconds since last report + const hasStateChanged = stateFingerprint !== lastReportedStateRef.current; + const timeThresholdMet = now - lastReportTime.current >= 5000; + + if (!hasStateChanged && !timeThresholdMet) return; + + let contentTitle = "Unknown content"; + let contentType = ""; + + if (meta) { + if (meta.type === "movie") { + contentTitle = meta.title; + contentType = "Movie"; + } else if (meta.type === "show" && meta.episode) { + contentTitle = `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`; + contentType = "TV Show"; + } + } + + // Send to our backend instead of Discord + const sendToBackend = async () => { + try { + await sendPlayerStatus({ + userId, + roomCode: roomCode || 'none', + isHost: isHost || false, + content: { + title: contentTitle, + type: contentType || 'Unknown', + }, + player: { + isPlaying: latestStatus.isPlaying, + isPaused: latestStatus.isPaused, + isLoading: latestStatus.isLoading, + hasPlayedOnce: latestStatus.hasPlayedOnce, + time: latestStatus.time, + duration: latestStatus.duration, + volume: latestStatus.volume, + playbackRate: latestStatus.playbackRate, + buffered: latestStatus.buffered, + }, + }); + + // Update last report time and fingerprint + lastReportTime.current = now; + lastReportedStateRef.current = stateFingerprint; + + console.log("Sent player status update to backend", { + time: new Date().toISOString(), + isPlaying: latestStatus.isPlaying, + currentTime: Math.floor(latestStatus.time), + userId, + content: contentTitle, + roomCode, + }); + } catch (error) { + console.error("Failed to send player status to backend", error); + } + }; + + sendToBackend(); + }, [ + latestStatus, + statusHistory.length, + userId, + account, + meta, + watchPartyEnabled, + roomCode, + isHost, + ]); + */ +} \ No newline at end of file diff --git a/server/api/player/README.md b/server/api/player/README.md new file mode 100644 index 0000000..123b8d3 --- /dev/null +++ b/server/api/player/README.md @@ -0,0 +1,143 @@ +# Player Status API + +This API allows for tracking and retrieving player status data for users in watch party rooms. Status data is automatically cleaned up if it's older than 1 minute. + +## Endpoints + +### POST `/api/player/status` + +Send a player status update. + +**Request Body:** + +```json +{ + "userId": "user123", // Required: User identifier + "roomCode": "room456", // Required: Room code + "isHost": true, // Optional: Whether the user is the host + "content": { // Optional: Content information + "title": "Movie Title", + "type": "Movie", // "Movie", "TV Show", etc. + "tmdbId": 12345, // Optional: TMDB ID for the content + "seasonNumber": 1, // Optional: Season number (for TV shows) + "episodeNumber": 3 // Optional: Episode number (for TV shows) + }, + "player": { // Optional: Player state + "isPlaying": true, + "isPaused": false, + "isLoading": false, + "hasPlayedOnce": true, + "time": 120.5, // Current playback position in seconds + "duration": 3600, // Total content duration in seconds + "volume": 0.8, // Volume level (0-1) + "playbackRate": 1, // Playback speed + "buffered": 180 // Buffered seconds + } +} +``` + +**Response:** + +```json +{ + "success": true, + "timestamp": 1625097600000 // The timestamp assigned to this status update +} +``` + +### GET `/api/player/status?userId=user123&roomCode=room456` + +Get status updates for a specific user in a specific room. + +**Query Parameters:** +- `userId`: User identifier +- `roomCode`: Room code + +**Response:** + +```json +{ + "userId": "user123", + "roomCode": "room456", + "statuses": [ + { + "userId": "user123", + "roomCode": "room456", + "isHost": true, + "content": { + "title": "Movie Title", + "type": "Movie", + "tmdbId": 12345, + "seasonNumber": null, + "episodeNumber": null + }, + "player": { + "isPlaying": true, + "isPaused": false, + "isLoading": false, + "hasPlayedOnce": true, + "time": 120.5, + "duration": 3600, + "volume": 0.8, + "playbackRate": 1, + "buffered": 180 + }, + "timestamp": 1625097600000 + } + // More status updates if available + ] +} +``` + +### GET `/api/player/status?roomCode=room456` + +Get status updates for all users in a specific room. + +**Query Parameters:** +- `roomCode`: Room code + +**Response:** + +```json +{ + "roomCode": "room456", + "users": { + "user123": [ + { + "userId": "user123", + "roomCode": "room456", + "isHost": true, + "content": { + "title": "Show Title", + "type": "TV Show", + "tmdbId": 67890, + "seasonNumber": 2, + "episodeNumber": 5 + }, + "player": { + "isPlaying": true, + "isPaused": false, + "isLoading": false, + "hasPlayedOnce": true, + "time": 120.5, + "duration": 3600, + "volume": 0.8, + "playbackRate": 1, + "buffered": 180 + }, + "timestamp": 1625097600000 + } + // More status updates for this user if available + ], + "user456": [ + // Status updates for another user + ] + } +} +``` + +## Notes + +- Status data is automatically cleaned up if it's older than 1 minute +- The system keeps a maximum of 5 status updates per user per room +- Timestamps are in milliseconds since epoch (Unix timestamp) \ No newline at end of file diff --git a/server/api/player/status.get.ts b/server/api/player/status.get.ts new file mode 100644 index 0000000..79f2b71 --- /dev/null +++ b/server/api/player/status.get.ts @@ -0,0 +1,56 @@ +import { defineEventHandler, getQuery, createError } from 'h3'; +import { playerStatusStore, CLEANUP_INTERVAL } from '~/utils/playerStatus'; + +export default defineEventHandler((event) => { + const query = getQuery(event); + const userId = query.userId as string; + const roomCode = query.roomCode as string; + + // If roomCode is provided but no userId, return all statuses for that room + if (roomCode && !userId) { + const cutoffTime = Date.now() - CLEANUP_INTERVAL; + const roomStatuses: Record = {}; + + for (const [key, statuses] of playerStatusStore.entries()) { + if (key.includes(`:${roomCode}`)) { + const userId = key.split(':')[0]; + const recentStatuses = statuses.filter(status => status.timestamp >= cutoffTime); + + if (recentStatuses.length > 0) { + roomStatuses[userId] = recentStatuses; + } + } + } + + return { + roomCode, + users: roomStatuses + }; + } + + // If both userId and roomCode are provided, return status for that user in that room + if (userId && roomCode) { + const key = `${userId}:${roomCode}`; + const statuses = playerStatusStore.get(key) || []; + + // Remove statuses older than 15 minutes + const cutoffTime = Date.now() - CLEANUP_INTERVAL; + const recentStatuses = statuses.filter(status => status.timestamp >= cutoffTime); + + if (recentStatuses.length !== statuses.length) { + playerStatusStore.set(key, recentStatuses); + } + + return { + userId, + roomCode, + statuses: recentStatuses + }; + } + + // If neither is provided, return error + throw createError({ + statusCode: 400, + statusMessage: 'Missing required query parameters: roomCode and/or userId' + }); +}); \ No newline at end of file diff --git a/server/api/player/status.post.ts b/server/api/player/status.post.ts new file mode 100644 index 0000000..47aa2b2 --- /dev/null +++ b/server/api/player/status.post.ts @@ -0,0 +1,53 @@ +import { defineEventHandler, readBody, createError } from 'h3'; +import { playerStatusStore, PlayerStatus } from '~/utils/playerStatus'; + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + + if (!body || !body.userId || !body.roomCode) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing required fields: userId, roomCode' + }); + } + + const status: PlayerStatus = { + userId: body.userId, + roomCode: body.roomCode, + isHost: body.isHost || false, + content: { + title: body.content?.title || 'Unknown', + type: body.content?.type || 'Unknown', + tmdbId: body.content?.tmdbId, + seasonId: body.content?.seasonId, + episodeId: body.content?.episodeId, + seasonNumber: body.content?.seasonNumber, + episodeNumber: body.content?.episodeNumber + }, + player: { + isPlaying: body.player?.isPlaying || false, + isPaused: body.player?.isPaused || false, + isLoading: body.player?.isLoading || false, + hasPlayedOnce: body.player?.hasPlayedOnce || false, + time: body.player?.time || 0, + duration: body.player?.duration || 0, + volume: body.player?.volume || 0, + playbackRate: body.player?.playbackRate || 1, + buffered: body.player?.buffered || 0, + }, + timestamp: Date.now() + }; + + const key = `${status.userId}:${status.roomCode}`; + const existingStatuses = playerStatusStore.get(key) || []; + + // Add new status and keep only the last 5 statuses + existingStatuses.push(status); + if (existingStatuses.length > 5) { + existingStatuses.shift(); + } + + playerStatusStore.set(key, existingStatuses); + + return { success: true, timestamp: status.timestamp }; +}); \ No newline at end of file diff --git a/server/utils/playerStatus.ts b/server/utils/playerStatus.ts new file mode 100644 index 0000000..2453871 --- /dev/null +++ b/server/utils/playerStatus.ts @@ -0,0 +1,52 @@ +// Interface for player status +export interface PlayerStatus { + userId: string; + roomCode: string; + isHost: boolean; + content: { + title: string; + type: string; + tmdbId?: number | string; + seasonId?: number; + episodeId?: number; + seasonNumber?: number; + episodeNumber?: number; + }; + player: { + isPlaying: boolean; + isPaused: boolean; + isLoading: boolean; + hasPlayedOnce: boolean; + time: number; + duration: number; + volume: number; + playbackRate: number; + buffered: number; + }; + timestamp: number; +} + +// In-memory store for player status data +// Key: userId+roomCode, Value: Status data array +export const playerStatusStore = new Map>(); + +// Cleanup interval (1 minute in milliseconds) +export const CLEANUP_INTERVAL = 1 * 60 * 1000; + +// Clean up old status entries +function cleanupOldStatuses() { + const cutoffTime = Date.now() - CLEANUP_INTERVAL; + + for (const [key, statuses] of playerStatusStore.entries()) { + const filteredStatuses = statuses.filter(status => status.timestamp >= cutoffTime); + + if (filteredStatuses.length === 0) { + playerStatusStore.delete(key); + } else { + playerStatusStore.set(key, filteredStatuses); + } + } +} + +// Schedule cleanup every 1 minute +setInterval(cleanupOldStatuses, 1 * 60 * 1000); \ No newline at end of file