First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
/**
* Haptic feedback utility for mobile interactions
* Provides tactile feedback for user actions
*/
type HapticStyle = "light" | "medium" | "heavy" | "success" | "warning" | "error";
/**
* Trigger haptic feedback (if supported by the device)
*/
export function haptic(style: HapticStyle = "medium") {
// Check if running in a mobile environment with haptic support
if (!("vibrate" in navigator)) {
return;
}
// Map haptic styles to vibration patterns
const patterns: Record<HapticStyle, number | number[]> = {
light: 10,
medium: 20,
heavy: 40,
success: [10, 50, 10], // Double tap pattern
warning: [20, 100, 20, 100, 20], // Triple tap pattern
error: 50,
};
try {
navigator.vibrate(patterns[style]);
} catch (error) {
// Silently fail if vibration is not supported or blocked
console.debug("Haptic feedback not available:", error);
}
}
/**
* Haptic feedback for common UI interactions
*/
export const haptics = {
/** Light tap (e.g., button press) */
tap: () => haptic("light"),
/** Selection change (e.g., toggle, checkbox) */
select: () => haptic("medium"),
/** Successful action (e.g., item added, saved) */
success: () => haptic("success"),
/** Warning or important action (e.g., delete confirmation) */
warning: () => haptic("warning"),
/** Error or failed action */
error: () => haptic("error"),
/** Heavy impact (e.g., drag and drop) */
impact: () => haptic("heavy"),
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Menu position calculation utility
* Calculates optimal position for dropdown menus to avoid viewport clipping
*/
export interface MenuPosition {
x: number;
y: number;
placement: 'bottom' | 'top';
}
/**
* Calculate the optimal position for a menu dropdown
* @param triggerElement - The button/element that triggers the menu
* @param menuWidth - Estimated or actual menu width (default: 160px)
* @param menuHeight - Estimated or actual menu height (default: 120px)
* @returns Position object with x, y coordinates and placement direction
*/
export function calculateMenuPosition(
triggerElement: HTMLElement,
menuWidth: number = 160,
menuHeight: number = 120
): MenuPosition {
const rect = triggerElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// Determine vertical placement (below or above trigger)
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
const fitsBelow = spaceBelow >= menuHeight + 8; // 8px margin
const fitsAbove = spaceAbove >= menuHeight + 8;
let y: number;
let placement: 'bottom' | 'top';
if (fitsBelow) {
// Prefer below if there's space
y = rect.bottom + 4; // 4px gap
placement = 'bottom';
} else if (fitsAbove) {
// Show above if no space below
y = rect.top - menuHeight - 4; // 4px gap
placement = 'top';
} else {
// Not enough space either way - prefer below and let it extend
y = rect.bottom + 4;
placement = 'bottom';
}
// Horizontal positioning - align right edge of menu with right edge of button
let x = rect.right - menuWidth;
// Ensure menu doesn't overflow left edge of viewport
if (x < 8) {
x = 8; // 8px margin from left edge
}
// Ensure menu doesn't overflow right edge of viewport
if (x + menuWidth > viewportWidth - 8) {
x = viewportWidth - menuWidth - 8; // 8px margin from right edge
}
return { x, y, placement };
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Playback unit conversion utilities
*
* Jellyfin uses "ticks" for time values where 10 million ticks = 1 second.
* This module provides type-safe conversion functions to eliminate magic numbers
* and prevent conversion bugs across the codebase.
*/
/**
* Number of Jellyfin ticks per second (10 million)
*/
export const TICKS_PER_SECOND = 10_000_000;
/**
* Convert seconds to Jellyfin ticks
* @param seconds - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
* @returns Time in Jellyfin ticks
*/
export function secondsToTicks(seconds: number): number {
return Math.floor(seconds * TICKS_PER_SECOND);
}
/**
* Convert Jellyfin ticks to seconds
* @param ticks - Time in Jellyfin ticks
* @returns Time in seconds
*/
export function ticksToSeconds(ticks: number): number {
return ticks / TICKS_PER_SECOND;
}
/**
* Convert normalized volume (0-1) to percentage (0-100)
* Used when sending volume to Jellyfin remote sessions
* @param volume - Normalized volume (0.0 to 1.0)
* @returns Volume as percentage (0 to 100)
*/
export function volumeToPercent(volume: number): number {
return Math.floor(Math.max(0, Math.min(1, volume)) * 100);
}
/**
* Convert percentage volume (0-100) to normalized (0-1)
* Used when receiving volume from Jellyfin remote sessions
* @param percent - Volume as percentage (0 to 100)
* @returns Normalized volume (0.0 to 1.0)
*/
export function percentToVolume(percent: number): number {
return Math.max(0, Math.min(100, percent)) / 100;
}
/**
* Format time in seconds to MM:SS display string
* @param seconds - Time in seconds
* @returns Formatted string like "3:45" or "12:09"
*/
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
/**
* Format time in seconds to HH:MM:SS display string (for longer content)
* @param seconds - Time in seconds
* @returns Formatted string like "1:23:45" or "0:03:45"
*/
export function formatTimeLong(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
/**
* Calculate progress percentage
* @param position - Current position in seconds
* @param duration - Total duration in seconds
* @returns Progress as percentage (0 to 100)
*/
export function calculateProgress(position: number, duration: number): number {
if (duration <= 0) return 0;
return Math.min(100, Math.max(0, (position / duration) * 100));
}