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
+28
View File
@@ -0,0 +1,28 @@
/**
* Autoplay API
*
* Functions to control autoplay settings in the backend.
*/
import { invoke } from "@tauri-apps/api/core";
export interface AutoplaySettings {
enabled: boolean;
countdownSeconds: number;
}
export async function getAutoplaySettings(): Promise<AutoplaySettings> {
return invoke("player_get_autoplay_settings");
}
export async function setAutoplaySettings(settings: AutoplaySettings): Promise<AutoplaySettings> {
return invoke("player_set_autoplay_settings", { settings });
}
export async function cancelAutoplayCountdown(): Promise<void> {
return invoke("player_cancel_autoplay_countdown");
}
export async function playNextEpisode(item: any): Promise<void> {
return invoke("player_play_next_episode", { item });
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Rust-based conversion utilities via Tauri commands
*
* These functions provide centralized conversion logic in Rust.
* All conversions are handled by the Rust backend for consistency.
*/
import { invoke } from '@tauri-apps/api/core';
/**
* Format time in seconds to MM:SS display string
* @param seconds - Time in seconds
* @returns Formatted string like "3:45" or "12:09"
*/
export async function formatTime(seconds: number): Promise<string> {
return invoke('format_time_seconds', { seconds });
}
/**
* Format time in seconds to HH:MM:SS or MM:SS display string
* Automatically chooses format based on duration
* @param seconds - Time in seconds
* @returns Formatted string like "1:23:45" or "3:45"
*/
export async function formatTimeLong(seconds: number): Promise<string> {
return invoke('format_time_seconds_long', { seconds });
}
/**
* Convert Jellyfin ticks to seconds
* @param ticks - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
* @returns Time in seconds
*/
export async function ticksToSeconds(ticks: number): Promise<number> {
return invoke('convert_ticks_to_seconds', { ticks });
}
/**
* Calculate progress percentage from position and duration
* @param position - Current position in seconds
* @param duration - Total duration in seconds
* @returns Progress as percentage (0.0 to 100.0)
*/
export async function calculateProgress(position: number, duration: number): Promise<number> {
return invoke('calc_progress', { position, duration });
}
/**
* Convert percentage volume (0-100) to normalized (0.0-1.0)
* @param percent - Volume as percentage (0 to 100)
* @returns Normalized volume (0.0 to 1.0)
*/
export async function percentToVolume(percent: number): Promise<number> {
return invoke('convert_percent_to_volume', { percent });
}
/**
* Synchronous time formatting utilities for high-frequency UI updates
*
* These are kept in TypeScript for performance reasons when formatting
* needs to happen frequently (e.g., position updates every 100ms).
*
* For less frequent conversions, prefer the async Rust-based functions above.
*/
/**
* Format time in seconds to MM:SS display string (synchronous)
* @param seconds - Time in seconds
* @returns Formatted string like "3:45" or "12:09"
*/
export function formatTimeSync(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 or MM:SS display string (synchronous)
* @param seconds - Time in seconds
* @returns Formatted string like "1:23:45" or "3:45"
*/
export function formatTimeLongSync(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 (synchronous)
* @param position - Current position in seconds
* @param duration - Total duration in seconds
* @returns Progress as percentage (0 to 100)
*/
export function calculateProgressSync(position: number, duration: number): number {
if (duration <= 0) return 0;
return Math.min(100, Math.max(0, (position / duration) * 100));
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Quality preset for video downloads
*/
export type QualityPreset = "original" | "high" | "medium" | "low";
/**
* Quality preset configuration
*/
export interface QualityPresetConfig {
videoBitrate: number | null;
audioBitrate: number;
maxHeight: number | null;
label: string;
}
/**
* Quality presets with their configuration
*/
export const QUALITY_PRESETS: Record<QualityPreset, QualityPresetConfig> = {
original: {
videoBitrate: null,
audioBitrate: 384000,
maxHeight: null,
label: "Original",
},
high: {
videoBitrate: 8_000_000,
audioBitrate: 384000,
maxHeight: 1080,
label: "1080p",
},
medium: {
videoBitrate: 4_000_000,
audioBitrate: 256000,
maxHeight: 720,
label: "720p",
},
low: {
videoBitrate: 1_500_000,
audioBitrate: 128000,
maxHeight: 480,
label: "480p",
},
};
+346
View File
@@ -0,0 +1,346 @@
// Thin TypeScript wrapper for Rust repository implementation
// All API calls go through Tauri commands in src-tauri/src/commands/repository.rs
// NO direct HTTP calls - everything routes through Rust backend
import { invoke } from "@tauri-apps/api/core";
import type { QualityPreset } from "./quality-presets";
import { QUALITY_PRESETS } from "./quality-presets";
import type {
Library,
MediaItem,
SearchResult,
GetItemsOptions,
SearchOptions,
PlaybackInfo,
ImageType,
ImageOptions,
Genre,
} from "./types";
/**
* Repository client - thin wrapper over Rust HybridRepository
* Uses handle-based system: create() returns a UUID handle for all operations
*/
export class RepositoryClient {
private handle: string | null = null;
private _serverUrl: string | null = null;
private _accessToken: string | null = null;
/**
* Create a new repository instance in Rust
* Returns the repository handle for subsequent operations
*/
async create(
serverUrl: string,
userId: string,
accessToken: string,
serverId: string
): Promise<string> {
console.log("[RepositoryClient] Creating Rust repository...");
this.handle = await invoke<string>("repository_create", {
serverUrl,
userId,
accessToken,
serverId,
});
// Store for URL construction
this._serverUrl = serverUrl;
this._accessToken = accessToken;
console.log("[RepositoryClient] Repository created with handle:", this.handle);
return this.handle;
}
/**
* Destroy the repository instance in Rust
* Call this on logout or when switching servers
*/
async destroy(): Promise<void> {
if (this.handle) {
await invoke("repository_destroy", { handle: this.handle });
this.handle = null;
this._serverUrl = null;
this._accessToken = null;
}
}
private ensureHandle(): string {
if (!this.handle) {
throw new Error("Repository not initialized - call create() first");
}
return this.handle;
}
/**
* Get the repository handle for passing to backend commands
*/
getHandle(): string {
return this.ensureHandle();
}
// ===== Library Methods (all via Rust) =====
async getLibraries(): Promise<Library[]> {
return invoke<Library[]>("repository_get_libraries", {
handle: this.ensureHandle(),
});
}
async getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_items", {
handle: this.ensureHandle(),
parentId,
options: options ?? null,
});
}
async getItem(itemId: string): Promise<MediaItem> {
return invoke<MediaItem>("repository_get_item", {
handle: this.ensureHandle(),
itemId,
});
}
async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_latest_items", {
handle: this.ensureHandle(),
parentId,
limit: limit ?? null,
});
}
async getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_resume_items", {
handle: this.ensureHandle(),
parentId: parentId ?? null,
limit: limit ?? null,
});
}
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_next_up_episodes", {
handle: this.ensureHandle(),
seriesId: seriesId ?? null,
limit: limit ?? null,
});
}
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_recently_played_audio", {
handle: this.ensureHandle(),
limit: limit ?? null,
});
}
async getResumeMovies(limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_resume_movies", {
handle: this.ensureHandle(),
limit: limit ?? null,
});
}
async getGenres(parentId?: string): Promise<Genre[]> {
return invoke<Genre[]>("repository_get_genres", {
handle: this.ensureHandle(),
parentId: parentId ?? null,
});
}
async search(query: string, options?: SearchOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_search", {
handle: this.ensureHandle(),
query,
options: options ?? null,
});
}
// ===== Playback Methods (all via Rust) =====
async getPlaybackInfo(itemId: string): Promise<PlaybackInfo> {
return invoke<PlaybackInfo>("repository_get_playback_info", {
handle: this.ensureHandle(),
itemId,
});
}
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_start", {
handle: this.ensureHandle(),
itemId,
positionTicks,
});
}
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_progress", {
handle: this.ensureHandle(),
itemId,
positionTicks,
});
}
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_stopped", {
handle: this.ensureHandle(),
itemId,
positionTicks,
});
}
// ===== Stream URL Methods (via Rust) =====
async getAudioStreamUrl(itemId: string): Promise<string> {
return invoke<string>("repository_get_audio_stream_url", {
handle: this.ensureHandle(),
itemId,
});
}
async getVideoStreamUrl(
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number
): Promise<string> {
return invoke<string>("repository_get_video_stream_url", {
handle: this.ensureHandle(),
itemId,
mediaSourceId: mediaSourceId ?? null,
startTimeSeconds: startTimeSeconds ?? null,
audioStreamIndex: audioStreamIndex ?? null,
});
}
// ===== URL Construction Methods (sync, no server call) =====
/**
* Get image URL - constructs URL synchronously (no server call)
*/
getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): string {
if (!this._serverUrl || !this._accessToken) {
throw new Error("Repository not initialized - call create() first");
}
let url = `${this._serverUrl}/Items/${itemId}/Images/${imageType}`;
const params: string[] = [`api_key=${this._accessToken}`];
if (options) {
if (options.maxWidth) params.push(`maxWidth=${options.maxWidth}`);
if (options.maxHeight) params.push(`maxHeight=${options.maxHeight}`);
if (options.quality) params.push(`quality=${options.quality}`);
if (options.tag) params.push(`tag=${options.tag}`);
}
return `${url}?${params.join('&')}`;
}
/**
* Get subtitle URL - constructs URL synchronously (no server call)
*/
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format: string = "vtt"): string {
if (!this._serverUrl || !this._accessToken) {
throw new Error("Repository not initialized - call create() first");
}
return `${this._serverUrl}/Videos/${itemId}/${mediaSourceId}/Subtitles/${streamIndex}/Stream.${format}?api_key=${this._accessToken}`;
}
/**
* Get video download URL with quality preset - constructs URL synchronously
* Used for offline downloads
*/
getVideoDownloadUrl(
itemId: string,
quality: QualityPreset = "original",
mediaSourceId?: string
): string {
if (!this._serverUrl || !this._accessToken) {
throw new Error("Repository not initialized - call create() first");
}
const preset = QUALITY_PRESETS[quality];
if (quality === "original" || !preset.videoBitrate) {
// Direct stream for original quality
const params = new URLSearchParams({
api_key: this._accessToken,
Static: "true",
audioStreamIndex: "0",
});
if (mediaSourceId) {
params.append("MediaSourceId", mediaSourceId);
}
return `${this._serverUrl}/Videos/${itemId}/stream?${params.toString()}`;
}
// Transcoded download with quality preset
const params = new URLSearchParams({
api_key: this._accessToken,
DeviceId: localStorage.getItem("jellytau_device_id") || "jellytau",
Container: "mp4",
VideoCodec: "h264",
AudioCodec: "aac",
AudioStreamIndex: "0",
VideoBitrate: preset.videoBitrate.toString(),
AudioBitrate: preset.audioBitrate.toString(),
MaxHeight: preset.maxHeight?.toString() ?? "",
TranscodingMaxAudioChannels: "2",
});
if (mediaSourceId) {
params.append("MediaSourceId", mediaSourceId);
}
return `${this._serverUrl}/Videos/${itemId}/stream.mp4?${params.toString()}`;
}
// ===== Favorite Methods (via Rust) =====
async markFavorite(itemId: string): Promise<void> {
return invoke("repository_mark_favorite", {
handle: this.ensureHandle(),
itemId,
});
}
async unmarkFavorite(itemId: string): Promise<void> {
return invoke("repository_unmark_favorite", {
handle: this.ensureHandle(),
itemId,
});
}
// ===== Person Methods (via Rust) =====
async getPerson(personId: string): Promise<MediaItem> {
return invoke<MediaItem>("repository_get_person", {
handle: this.ensureHandle(),
personId,
});
}
async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_items_by_person", {
handle: this.ensureHandle(),
personId,
options: options ?? null,
});
}
async getSimilarItems(itemId: string, limit?: number): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_similar_items", {
handle: this.ensureHandle(),
itemId,
limit: limit ?? null,
});
}
// ===== Getters =====
get serverUrl(): string {
if (!this._serverUrl) {
throw new Error("Repository not initialized - call create() first");
}
return this._serverUrl;
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Sleep Timer API
*
* Functions to control the sleep timer in the backend.
*/
import { invoke } from "@tauri-apps/api/core";
import type { SleepTimerMode } from "$lib/services/playerEvents";
export interface SleepTimerState {
mode: SleepTimerMode;
remainingSeconds: number;
}
export async function setSleepTimer(mode: SleepTimerMode): Promise<SleepTimerState> {
return invoke("player_set_sleep_timer", { mode });
}
export async function cancelSleepTimer(): Promise<SleepTimerState> {
return invoke("player_cancel_sleep_timer");
}
export async function getSleepTimer(): Promise<SleepTimerState> {
return invoke("player_get_sleep_timer");
}
// Helper functions for common timer modes
export async function setTimeBasedTimer(minutes: number): Promise<SleepTimerState> {
const endTime = Date.now() + minutes * 60 * 1000;
return setSleepTimer({ kind: "time", endTime });
}
export async function setEndOfTrackTimer(): Promise<SleepTimerState> {
return setSleepTimer({ kind: "endOfTrack" });
}
export async function setEpisodesTimer(count: number): Promise<SleepTimerState> {
return setSleepTimer({ kind: "episodes", remaining: count });
}
+264
View File
@@ -0,0 +1,264 @@
// Jellyfin API Types
export interface ServerInfo {
id: string;
name: string;
url: string;
}
export interface User {
id: string;
name: string;
serverId: string;
primaryImageTag?: string;
}
export interface AuthResult {
user: User;
accessToken: string;
serverId: string;
}
export interface Library {
id: string;
name: string;
collectionType: LibraryType;
imageTag?: string;
}
export type LibraryType = "movies" | "tvshows" | "music" | "books" | "photos" | "homevideos" | "boxsets" | "playlists" | "channels" | "unknown";
export interface MediaItem {
id: string;
name: string;
type: ItemType;
serverId: string;
// Common fields
overview?: string;
productionYear?: number;
communityRating?: number;
officialRating?: string;
runTimeTicks?: number;
// Images
primaryImageTag?: string;
backdropImageTags?: string[];
parentBackdropImageTags?: string[];
// For audio
albumId?: string;
albumName?: string;
artists?: string[];
artistItems?: { id: string; name: string }[];
indexNumber?: number; // Track number
parentIndexNumber?: number; // Disc number
// For video
seriesId?: string;
seriesName?: string;
seasonId?: string;
seasonName?: string;
// Playback
userData?: UserData;
mediaStreams?: MediaStream[];
mediaSources?: MediaSource[];
// Cast & Crew
people?: Person[];
}
export type ItemType =
| "Movie"
| "Series"
| "Season"
| "Episode"
| "MusicAlbum"
| "MusicArtist"
| "Audio"
| "Playlist"
| "Folder"
| "CollectionFolder"
| "Channel"
| "ChannelFolderItem"
| "Person";
// Person/Cast types
export type PersonType =
| "Actor"
| "Director"
| "Writer"
| "Producer"
| "Composer"
| "GuestStar"
| "Creator"
| "Conductor"
| "Lyricist";
export interface Person {
id: string;
name: string;
role?: string; // Character name for actors
type: PersonType;
primaryImageTag?: string;
}
export interface UserData {
playbackPositionTicks: number;
playCount: number;
isFavorite: boolean;
played: boolean;
lastPlayedDate?: string;
}
export interface MediaStream {
type: "Video" | "Audio" | "Subtitle";
codec?: string;
language?: string;
displayTitle?: string;
index: number;
isDefault: boolean;
isForced: boolean;
}
export interface MediaSource {
id: string;
name: string;
container?: string;
size?: number;
bitrate?: number;
supportsDirectPlay: boolean;
supportsDirectStream: boolean;
supportsTranscoding: boolean;
directStreamUrl?: string;
}
export interface PlaybackInfo {
mediaSourceId: string;
playSessionId: string;
streamUrl: string;
directPlay: boolean;
/** True if content requires transcoding (HEVC, 10-bit) - seeking requires server-side StartTimeTicks */
needsTranscoding: boolean;
}
// Remote session control types
export interface Session {
id: string;
userId: string;
userName: string;
client: string;
deviceName: string;
deviceId: string;
applicationVersion: string;
isActive: boolean;
supportsMediaControl: boolean;
supportsRemoteControl: boolean;
playState: PlayState | null;
nowPlayingItem: MediaItem | null;
playableMediaTypes: string[];
supportedCommands: string[];
}
export interface PlayState {
positionTicks: number;
canSeek: boolean;
isPaused: boolean;
isMuted: boolean;
volumeLevel: number;
repeatMode: string;
shuffleMode: string;
}
export type SessionCommand =
| "PlayPause"
| "Stop"
| "Pause"
| "Unpause"
| "NextTrack"
| "PreviousTrack"
| "Mute"
| "Unmute";
export interface SearchResult {
items: MediaItem[];
totalRecordCount: number;
}
// Repository pattern for offline support
export interface MediaRepository {
// Libraries
getLibraries(): Promise<Library[]>;
// Items
getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult>;
getItem(itemId: string): Promise<MediaItem>;
getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]>;
// Home screen
getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]>;
getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]>;
getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]>;
getResumeMovies(limit?: number): Promise<MediaItem[]>;
// Genres
getGenres(parentId?: string): Promise<Genre[]>;
// Search
search(query: string, options?: SearchOptions): Promise<SearchResult>;
// Playback
getPlaybackInfo(itemId: string): Promise<PlaybackInfo>;
reportPlaybackStart(itemId: string, positionTicks: number): Promise<void>;
reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void>;
reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void>;
// Images
getImageUrl(itemId: string, imageType: ImageType, options?: ImageOptions): string;
// Subtitles
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format?: string): string;
// Favorites
markFavorite(itemId: string): Promise<void>;
unmarkFavorite(itemId: string): Promise<void>;
// People/Cast
getPerson(personId: string): Promise<MediaItem>;
getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult>;
// Related Content
getSimilarItems(itemId: string, limit?: number): Promise<SearchResult>;
}
export interface Genre {
id: string;
name: string;
}
export interface GetItemsOptions {
startIndex?: number;
limit?: number;
sortBy?: string;
sortOrder?: "Ascending" | "Descending";
includeItemTypes?: ItemType[];
recursive?: boolean;
fields?: string[];
genres?: string[];
}
export interface SearchOptions {
limit?: number;
includeItemTypes?: ItemType[];
searchTerm?: string;
}
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo";
export interface ImageOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
tag?: string;
}