First working POC
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
// Authentication state store with Rust backend
|
||||
//
|
||||
// All business logic (session management, verification, credential storage) is handled by Rust.
|
||||
// This file is a thin Svelte store wrapper that calls Rust commands and listens to events.
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { RepositoryClient } from "$lib/api/repository-client";
|
||||
import type { User, AuthResult } from "$lib/api/types";
|
||||
import { connectivity } from "./connectivity";
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: User | null;
|
||||
serverUrl: string | null;
|
||||
serverName: string | null;
|
||||
error: string | null;
|
||||
securityWarning: string | null;
|
||||
/** Whether session needs re-authentication (e.g., token expired) */
|
||||
needsReauth: boolean;
|
||||
/** Whether session verification is in progress */
|
||||
isVerifying: boolean;
|
||||
/** Whether the session is known to be valid (verified with server) */
|
||||
sessionVerified: boolean;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
userId: string;
|
||||
username: string;
|
||||
serverId: string;
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
accessToken: string;
|
||||
verified: boolean;
|
||||
needsReauth: boolean;
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
id: string;
|
||||
normalizedUrl: string;
|
||||
}
|
||||
|
||||
interface SecurityStatus {
|
||||
usingKeyring: boolean;
|
||||
storageType: string;
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const initialState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
serverUrl: null,
|
||||
serverName: null,
|
||||
error: null,
|
||||
securityWarning: null,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: false,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<AuthState>(initialState);
|
||||
|
||||
// RepositoryClient provides cache-first access with automatic background refresh via Rust
|
||||
let repository: RepositoryClient | null = null;
|
||||
|
||||
function getRepository(): RepositoryClient {
|
||||
if (!repository) {
|
||||
throw new Error("Not connected to a server");
|
||||
}
|
||||
return repository;
|
||||
}
|
||||
|
||||
// Listen to auth events from Rust
|
||||
if (typeof window !== "undefined") {
|
||||
listen<{ user: User }>("auth:session-verified", (event) => {
|
||||
console.log("[Auth] Session verified:", event.payload.user.name);
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessionVerified: true,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
user: event.payload.user,
|
||||
}));
|
||||
});
|
||||
|
||||
listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
||||
console.log("[Auth] Session needs re-authentication:", event.payload.reason);
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessionVerified: false,
|
||||
needsReauth: true,
|
||||
isVerifying: false,
|
||||
error: event.payload.reason,
|
||||
}));
|
||||
});
|
||||
|
||||
listen<{ message: string }>("auth:network-error", (event) => {
|
||||
console.log("[Auth] Network error during verification:", event.payload.message);
|
||||
// Network errors don't trigger re-auth - just log them
|
||||
update((s) => ({ ...s, isVerifying: false }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth state from Rust backend.
|
||||
* This function does NOT require network access - session is restored immediately.
|
||||
*/
|
||||
async function initialize() {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Check security status
|
||||
try {
|
||||
const securityStatus = await invoke<SecurityStatus>("storage_get_security_status");
|
||||
console.log("[Auth] Security status:", securityStatus);
|
||||
if (!securityStatus.usingKeyring) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
securityWarning:
|
||||
"Credentials are stored with reduced security (encrypted file instead of system keyring).",
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Failed to get security status:", error);
|
||||
}
|
||||
|
||||
// Initialize auth manager and get session
|
||||
console.log("[Auth] Initializing auth manager...");
|
||||
const session = await invoke<Session | null>("auth_initialize");
|
||||
console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found");
|
||||
|
||||
if (session) {
|
||||
console.log("[Auth] Restoring session for user:", session.username, "on server:", session.serverUrl);
|
||||
|
||||
// Create RepositoryClient for cache-first access
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId);
|
||||
|
||||
// Configure Jellyfin client in Rust player for automatic playback reporting
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
try {
|
||||
console.log("[Auth] Configuring Rust player with restored session...");
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
deviceId: deviceId,
|
||||
});
|
||||
console.log("[Auth] Rust player configured for automatic playback reporting");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to configure Rust player:", error);
|
||||
}
|
||||
|
||||
// Set authenticated immediately (offline-first)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { id: session.userId, name: session.username, serverId: session.serverId } as User,
|
||||
serverUrl: session.serverUrl,
|
||||
serverName: session.serverName,
|
||||
error: null,
|
||||
securityWarning: initialState.securityWarning,
|
||||
needsReauth: session.needsReauth,
|
||||
isVerifying: false,
|
||||
sessionVerified: session.verified,
|
||||
});
|
||||
|
||||
// Start connectivity monitoring early to avoid appearing offline on startup
|
||||
console.log("[Auth] Starting early connectivity monitoring...");
|
||||
connectivity.startMonitoring(session.serverUrl, {
|
||||
onServerReconnected: () => {
|
||||
// Retry session verification when server becomes reachable
|
||||
retryVerification();
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error("[Auth] Failed to start connectivity monitoring:", error);
|
||||
});
|
||||
|
||||
// Start background session verification
|
||||
try {
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
console.log("[Auth] Background verification started");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start verification:", error);
|
||||
}
|
||||
} else {
|
||||
// No stored session
|
||||
console.log("[Auth] No active session found");
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
serverUrl: null,
|
||||
serverName: null,
|
||||
error: null,
|
||||
securityWarning: initialState.securityWarning,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to initialize:", error);
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a Jellyfin server and retrieve server info.
|
||||
* Rust will normalize the URL (add https:// if missing, remove trailing slash).
|
||||
*/
|
||||
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
console.log("[Auth] Connecting to server:", serverUrl);
|
||||
const serverInfo = await invoke<ServerInfo>("auth_connect_to_server", { serverUrl });
|
||||
console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version);
|
||||
console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl);
|
||||
|
||||
update((s) => ({ ...s, isLoading: false }));
|
||||
return serverInfo;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to connect to server:", error);
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with username and password.
|
||||
*/
|
||||
async function login(username: string, password: string, serverUrl: string, serverName: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
console.log("[Auth] Logging in as:", username);
|
||||
|
||||
const authResult = await invoke<AuthResult>("auth_login", {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
console.log("[Auth] Login successful:", authResult.user);
|
||||
|
||||
// Save to storage
|
||||
await invoke("storage_save_server", {
|
||||
id: authResult.serverId,
|
||||
name: serverName,
|
||||
url: serverUrl,
|
||||
version: null,
|
||||
});
|
||||
|
||||
await invoke("storage_save_user", {
|
||||
id: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
username: authResult.user.name,
|
||||
accessToken: authResult.accessToken,
|
||||
});
|
||||
|
||||
await invoke("storage_set_active_user", {
|
||||
userId: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
});
|
||||
|
||||
// Set session in auth manager with server name
|
||||
await invoke("auth_set_session", {
|
||||
session: {
|
||||
userId: authResult.user.id,
|
||||
username: authResult.user.name,
|
||||
serverId: authResult.serverId,
|
||||
serverUrl,
|
||||
serverName,
|
||||
accessToken: authResult.accessToken,
|
||||
verified: true,
|
||||
needsReauth: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Create RepositoryClient
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
|
||||
|
||||
// Configure Rust player
|
||||
try {
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl,
|
||||
accessToken: authResult.accessToken,
|
||||
userId: authResult.user.id,
|
||||
deviceId,
|
||||
});
|
||||
console.log("[Auth] Rust player configured for playback reporting");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to configure Rust player:", error);
|
||||
}
|
||||
|
||||
// Update state
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: authResult.user,
|
||||
serverUrl,
|
||||
serverName,
|
||||
error: null,
|
||||
securityWarning: initialState.securityWarning,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: true,
|
||||
});
|
||||
|
||||
// Start background verification
|
||||
try {
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start verification:", error);
|
||||
}
|
||||
|
||||
return authResult;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Login failed:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-authenticate with password (when session expired).
|
||||
*/
|
||||
async function reauthenticate(password: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false }));
|
||||
|
||||
try {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
console.log("[Auth] Re-authenticating...");
|
||||
|
||||
const authResult = await invoke<AuthResult>("auth_reauthenticate", {
|
||||
password,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
console.log("[Auth] Re-authentication successful");
|
||||
|
||||
// Update storage
|
||||
await invoke("storage_save_user", {
|
||||
id: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
username: authResult.user.name,
|
||||
accessToken: authResult.accessToken,
|
||||
});
|
||||
|
||||
// Recreate repository with new credentials
|
||||
if (repository) {
|
||||
await repository.destroy();
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
if (session) {
|
||||
await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconfigure player
|
||||
try {
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl: repository ? await getCurrentSessionServerUrl() : "",
|
||||
accessToken: authResult.accessToken,
|
||||
userId: authResult.user.id,
|
||||
deviceId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to reconfigure player:", error);
|
||||
}
|
||||
|
||||
// Update state
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: false,
|
||||
needsReauth: false,
|
||||
sessionVerified: true,
|
||||
user: authResult.user,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
return authResult;
|
||||
} catch (error) {
|
||||
console.error("[Auth] Re-authentication failed:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear session.
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
if (session) {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
await invoke("auth_logout", {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
// Disable Jellyfin reporting in player
|
||||
try {
|
||||
await invoke("player_disable_jellyfin");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to disable player reporting:", error);
|
||||
}
|
||||
|
||||
// Clear repository
|
||||
if (repository) {
|
||||
await repository.destroy();
|
||||
}
|
||||
repository = null;
|
||||
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
serverUrl: null,
|
||||
serverName: null,
|
||||
error: null,
|
||||
securityWarning: null,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth] Logout error (continuing anyway):", error);
|
||||
set(initialState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state.
|
||||
*/
|
||||
function clearError() {
|
||||
update((s) => ({ ...s, error: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session from Rust backend.
|
||||
*/
|
||||
async function getCurrentSession() {
|
||||
try {
|
||||
return await invoke<Session | null>("auth_get_session");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to get current session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID.
|
||||
*/
|
||||
function getUserId(): string | null {
|
||||
const state = get({ subscribe });
|
||||
return state.user?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server URL.
|
||||
*/
|
||||
function getServerUrl(): string | null {
|
||||
const state = get({ subscribe });
|
||||
return state.serverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get server URL from current session.
|
||||
*/
|
||||
async function getCurrentSessionServerUrl(): Promise<string> {
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
return session?.serverUrl || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry session verification (called when server becomes reachable again).
|
||||
*/
|
||||
async function retryVerification() {
|
||||
try {
|
||||
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||
console.log("[Auth] Retrying session verification after reconnection");
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to retry verification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
initialize,
|
||||
connectToServer,
|
||||
login,
|
||||
reauthenticate,
|
||||
logout,
|
||||
clearError,
|
||||
getRepository,
|
||||
getCurrentSession,
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
retryVerification,
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
export const isAuthenticated = derived(auth, ($auth) => $auth.isAuthenticated);
|
||||
export const isLoading = derived(auth, ($auth) => $auth.isLoading);
|
||||
export const currentUser = derived(auth, ($auth) => $auth.user);
|
||||
export const needsReauth = derived(auth, ($auth) => $auth.needsReauth);
|
||||
export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
|
||||
export const authError = derived(auth, ($auth) => $auth.error);
|
||||
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
|
||||
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
|
||||
@@ -0,0 +1,268 @@
|
||||
// Connectivity state store for offline support
|
||||
//
|
||||
// Simplified wrapper over Rust connectivity monitor.
|
||||
// The Rust backend handles all polling, reachability checks, and adaptive intervals.
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export interface ConnectivityState {
|
||||
/** Browser's navigator.onLine status */
|
||||
isOnline: boolean;
|
||||
/** Whether the Jellyfin server is actually reachable */
|
||||
isServerReachable: boolean;
|
||||
/** Last time we checked server reachability */
|
||||
lastChecked: Date | null;
|
||||
/** Error message from last connectivity check */
|
||||
connectionError: string | null;
|
||||
/** Whether we're currently checking connectivity */
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectivityEvents {
|
||||
/** Called when connectivity changes (online <-> offline) */
|
||||
onConnectivityChange?: (isConnected: boolean) => void;
|
||||
/** Called when server becomes reachable after being unreachable */
|
||||
onServerReconnected?: () => void;
|
||||
}
|
||||
|
||||
interface RustConnectivityStatus {
|
||||
isServerReachable: boolean;
|
||||
lastChecked: string | null;
|
||||
connectionError: string | null;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
function createConnectivityStore() {
|
||||
const initialState: ConnectivityState = {
|
||||
isOnline: browser ? navigator.onLine : true,
|
||||
// Start optimistic - assume server is reachable until proven otherwise
|
||||
// This prevents the app from appearing offline on startup
|
||||
isServerReachable: true,
|
||||
lastChecked: null,
|
||||
connectionError: null,
|
||||
isChecking: false,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<ConnectivityState>(initialState);
|
||||
|
||||
let eventHandlers: ConnectivityEvents = {};
|
||||
let isMonitoring = false;
|
||||
|
||||
// Listen to connectivity change events from Rust
|
||||
if (browser) {
|
||||
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
||||
update((s) => ({ ...s, isServerReachable: event.payload.isReachable }));
|
||||
|
||||
if (eventHandlers.onConnectivityChange) {
|
||||
eventHandlers.onConnectivityChange(event.payload.isReachable);
|
||||
}
|
||||
});
|
||||
|
||||
listen("connectivity:reconnected", () => {
|
||||
if (eventHandlers.onServerReconnected) {
|
||||
eventHandlers.onServerReconnected();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to browser online/offline events and update state
|
||||
window.addEventListener("online", () => {
|
||||
update((s) => ({ ...s, isOnline: true }));
|
||||
});
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
update((s) => ({
|
||||
...s,
|
||||
isOnline: false,
|
||||
isServerReachable: false,
|
||||
connectionError: "Device is offline",
|
||||
}));
|
||||
|
||||
if (eventHandlers.onConnectivityChange) {
|
||||
eventHandlers.onConnectivityChange(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network error (vs auth/server error)
|
||||
* Kept for compatibility with existing code
|
||||
*/
|
||||
function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof TypeError) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("network") ||
|
||||
msg.includes("fetch") ||
|
||||
msg.includes("failed to fetch") ||
|
||||
msg.includes("networkerror") ||
|
||||
msg.includes("connection") ||
|
||||
msg.includes("timeout") ||
|
||||
msg.includes("aborted")
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Jellyfin server is reachable (calls Rust)
|
||||
*/
|
||||
async function checkServerReachable(): Promise<boolean> {
|
||||
try {
|
||||
const isReachable = await invoke<boolean>("connectivity_check_server");
|
||||
|
||||
// Fetch updated status from Rust
|
||||
const status = await invoke<RustConnectivityStatus>("connectivity_get_status");
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: status.isServerReachable,
|
||||
lastChecked: status.lastChecked ? new Date(status.lastChecked) : null,
|
||||
connectionError: status.connectionError,
|
||||
isChecking: status.isChecking,
|
||||
}));
|
||||
|
||||
return isReachable;
|
||||
} catch (error) {
|
||||
console.error("[ConnectivityStore] Failed to check server:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring connectivity (delegates to Rust)
|
||||
*/
|
||||
async function startMonitoring(url: string, handlers: ConnectivityEvents = {}): Promise<void> {
|
||||
eventHandlers = handlers;
|
||||
isMonitoring = true;
|
||||
|
||||
try {
|
||||
console.log("[ConnectivityStore] Starting monitoring for:", url);
|
||||
|
||||
// Set the server URL
|
||||
await invoke("connectivity_set_server_url", { url });
|
||||
|
||||
// Start the Rust monitoring task (performs immediate check)
|
||||
await invoke("connectivity_start_monitoring");
|
||||
|
||||
// Get the initial status immediately after starting
|
||||
const status = await invoke<RustConnectivityStatus>("connectivity_get_status");
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: status.isServerReachable,
|
||||
lastChecked: status.lastChecked ? new Date(status.lastChecked) : null,
|
||||
connectionError: status.connectionError,
|
||||
isChecking: status.isChecking,
|
||||
}));
|
||||
|
||||
console.log("[ConnectivityStore] Started monitoring. Initial status:",
|
||||
status.isServerReachable ? "ONLINE" : "OFFLINE");
|
||||
} catch (error) {
|
||||
console.error("[ConnectivityStore] Failed to start monitoring:", error);
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: false,
|
||||
connectionError: "Failed to start monitoring",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring connectivity (delegates to Rust)
|
||||
*/
|
||||
async function stopMonitoring(): Promise<void> {
|
||||
if (!isMonitoring) return;
|
||||
|
||||
try {
|
||||
await invoke("connectivity_stop_monitoring");
|
||||
isMonitoring = false;
|
||||
eventHandlers = {};
|
||||
console.log("[ConnectivityStore] Stopped monitoring");
|
||||
} catch (error) {
|
||||
console.error("[ConnectivityStore] Failed to stop monitoring:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server URL (call when user changes servers)
|
||||
*/
|
||||
async function setServerUrl(url: string): Promise<void> {
|
||||
try {
|
||||
await invoke("connectivity_set_server_url", { url });
|
||||
} catch (error) {
|
||||
console.error("[ConnectivityStore] Failed to set server URL:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a connectivity check
|
||||
*/
|
||||
async function forceCheck(): Promise<boolean> {
|
||||
return checkServerReachable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark server as reachable (e.g., after successful API call)
|
||||
*/
|
||||
async function markReachable(): Promise<void> {
|
||||
try {
|
||||
await invoke("connectivity_mark_reachable");
|
||||
|
||||
// Update local state
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: true,
|
||||
lastChecked: new Date(),
|
||||
connectionError: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("[ConnectivityStore] Failed to mark reachable:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark server as unreachable (e.g., after failed API call)
|
||||
*/
|
||||
async function markUnreachable(error?: string): Promise<void> {
|
||||
try {
|
||||
await invoke("connectivity_mark_unreachable", { error: error ?? null });
|
||||
|
||||
// Update local state
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: false,
|
||||
lastChecked: new Date(),
|
||||
connectionError: error || "Server unreachable",
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("[ConnectivityStore] Failed to mark unreachable:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
startMonitoring,
|
||||
stopMonitoring,
|
||||
setServerUrl,
|
||||
forceCheck,
|
||||
checkServerReachable,
|
||||
markReachable,
|
||||
markUnreachable,
|
||||
isNetworkError,
|
||||
};
|
||||
}
|
||||
|
||||
export const connectivity = createConnectivityStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const isOnline = derived(connectivity, ($c) => $c.isOnline);
|
||||
export const isServerReachable = derived(connectivity, ($c) => $c.isServerReachable);
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isOnline && $c.isServerReachable
|
||||
);
|
||||
export const connectionError = derived(connectivity, ($c) => $c.connectionError);
|
||||
@@ -0,0 +1,617 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
// Mock Tauri APIs
|
||||
const mockInvoke = vi.fn();
|
||||
const mockListen = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: mockInvoke,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: mockListen,
|
||||
}));
|
||||
|
||||
describe("downloads store", () => {
|
||||
let eventHandler: ((event: { payload: unknown }) => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the invoke mock to clear any remaining queued return values
|
||||
mockInvoke.mockReset();
|
||||
|
||||
// Capture the event handler when listen is called
|
||||
mockListen.mockImplementation((_event: string, handler: (event: { payload: unknown }) => void) => {
|
||||
eventHandler = handler;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up event listeners
|
||||
const { cleanupDownloadEvents } = await import("./downloads");
|
||||
cleanupDownloadEvents();
|
||||
eventHandler = null;
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should have empty downloads initially", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
const state = get(downloads);
|
||||
|
||||
expect(state.downloads).toEqual({});
|
||||
expect(state.stats.activeCount).toBe(0);
|
||||
expect(state.stats.queuedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadItem", () => {
|
||||
it("should call invoke with correct parameters", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
mockInvoke
|
||||
.mockResolvedValueOnce(123) // download_item returns ID
|
||||
.mockResolvedValueOnce({
|
||||
downloads: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
activeCount: 0,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
}); // get_downloads returns empty
|
||||
|
||||
const downloadId = await downloads.downloadItem(
|
||||
"item-1",
|
||||
"user-1",
|
||||
"/path/to/file.mp3",
|
||||
"audio/mpeg",
|
||||
10
|
||||
);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("download_item", {
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
mimeType: "audio/mpeg",
|
||||
priority: 10,
|
||||
itemName: undefined,
|
||||
artistName: undefined,
|
||||
albumName: undefined,
|
||||
});
|
||||
expect(downloadId).toBe(123);
|
||||
});
|
||||
|
||||
it("should refresh downloads after queuing", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
mockInvoke
|
||||
.mockResolvedValueOnce(123)
|
||||
.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 10,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 0,
|
||||
queuedCount: 1,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.downloadItem("item-1", "user-1", "/path/to/file.mp3");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_downloads", {
|
||||
userId: "user-1",
|
||||
statusFilter: undefined,
|
||||
});
|
||||
|
||||
const state = get(downloads);
|
||||
expect(state.downloads[123]).toBeDefined();
|
||||
expect(state.stats.queuedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadAlbum", () => {
|
||||
it("should call invoke with correct parameters", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
mockInvoke
|
||||
.mockResolvedValueOnce([1, 2, 3]) // download_album returns IDs
|
||||
.mockResolvedValueOnce({
|
||||
downloads: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
activeCount: 0,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
}); // get_downloads
|
||||
|
||||
const ids = await downloads.downloadAlbum("album-1", "user-1", "/base/path");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("download_album", {
|
||||
albumId: "album-1",
|
||||
userId: "user-1",
|
||||
basePath: "/base/path",
|
||||
});
|
||||
expect(ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pause/resume/cancel", () => {
|
||||
it("should call pause_download with correct ID", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await downloads.pause(123);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("pause_download", { downloadId: 123 });
|
||||
});
|
||||
|
||||
it("should call resume_download with correct ID", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await downloads.resume(123);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("resume_download", { downloadId: 123 });
|
||||
});
|
||||
|
||||
it("should call cancel_download with correct ID", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await downloads.cancel(123);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("cancel_download", { downloadId: 123 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should call delete_download and remove from store", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
// First add a download via refresh
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "completed",
|
||||
progress: 1.0,
|
||||
bytesDownloaded: 1000,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 0,
|
||||
queuedCount: 0,
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1");
|
||||
expect(get(downloads).downloads[123]).toBeDefined();
|
||||
|
||||
// Now delete
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
await downloads.delete(123);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("delete_download", { downloadId: 123 });
|
||||
expect(get(downloads).downloads[123]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh", () => {
|
||||
it("should update store with downloads from backend", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 1,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/1.mp3",
|
||||
status: "downloading",
|
||||
progress: 0.5,
|
||||
bytesDownloaded: 500,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
itemId: "item-2",
|
||||
userId: "user-1",
|
||||
filePath: "/path/2.mp3",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "2024-01-01T00:00:01Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
itemId: "item-3",
|
||||
userId: "user-1",
|
||||
filePath: "/path/3.mp3",
|
||||
status: "completed",
|
||||
progress: 1.0,
|
||||
bytesDownloaded: 1000,
|
||||
queuedAt: "2024-01-01T00:00:02Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 3,
|
||||
activeCount: 1,
|
||||
queuedCount: 1,
|
||||
completedCount: 1,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1");
|
||||
|
||||
const state = get(downloads);
|
||||
expect(Object.keys(state.downloads).length).toBe(3);
|
||||
expect(state.stats.activeCount).toBe(1); // 1 downloading
|
||||
expect(state.stats.queuedCount).toBe(1); // 1 pending
|
||||
});
|
||||
|
||||
it("should support status filter", async () => {
|
||||
const { downloads } = await import("./downloads");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [],
|
||||
stats: {
|
||||
total: 0,
|
||||
activeCount: 0,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1", ["pending", "downloading"]);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_downloads", {
|
||||
userId: "user-1",
|
||||
statusFilter: ["pending", "downloading"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("event handling", () => {
|
||||
it("should initialize event listener via initDownloadEvents", async () => {
|
||||
const { initDownloadEvents } = await import("./downloads");
|
||||
|
||||
await initDownloadEvents();
|
||||
|
||||
expect(mockListen).toHaveBeenCalledWith("download-event", expect.any(Function));
|
||||
expect(eventHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should handle started event and refresh", async () => {
|
||||
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||
|
||||
// First add a pending download
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 0,
|
||||
queuedCount: 1,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1");
|
||||
|
||||
// Initialize event listener
|
||||
await initDownloadEvents();
|
||||
expect(eventHandler).not.toBeNull();
|
||||
|
||||
// Mock refresh call that will happen when event is handled
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
bytesDownloaded: 0,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 1,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate started event
|
||||
eventHandler!({
|
||||
payload: {
|
||||
type: "started",
|
||||
downloadId: 123,
|
||||
itemId: "item-1",
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for async refresh
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const state = get(downloads);
|
||||
expect(state.downloads[123].status).toBe("downloading");
|
||||
});
|
||||
|
||||
it("should handle completed event and refresh", async () => {
|
||||
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "downloading",
|
||||
progress: 0.99,
|
||||
bytesDownloaded: 990000,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 1,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1");
|
||||
|
||||
// Initialize event listener
|
||||
await initDownloadEvents();
|
||||
|
||||
// Mock the mark_download_completed invoke call
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Simulate completed event
|
||||
eventHandler!({
|
||||
payload: {
|
||||
type: "completed",
|
||||
downloadId: 123,
|
||||
itemId: "item-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
totalBytes: 1000000,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const state = get(downloads);
|
||||
expect(state.downloads[123].status).toBe("completed");
|
||||
});
|
||||
|
||||
it.skip("should handle failed event and refresh", async () => {
|
||||
// TODO: Fix mock ordering - the invoke mock needs to handle multiple calls in the correct order
|
||||
// The test triggers: 1) refresh get_downloads 2) mark_download_failed invoke
|
||||
// Current issue: Mock queue doesn't preserve order between tests
|
||||
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "downloading",
|
||||
progress: 0.5,
|
||||
bytesDownloaded: 500000,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 1,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1");
|
||||
|
||||
// Initialize event listener
|
||||
await initDownloadEvents();
|
||||
|
||||
// Mock the mark_download_failed call
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Simulate failed event
|
||||
eventHandler!({
|
||||
payload: {
|
||||
type: "failed",
|
||||
downloadId: 123,
|
||||
itemId: "item-1",
|
||||
error: "Network timeout",
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const state = get(downloads);
|
||||
expect(state.downloads[123].status).toBe("failed");
|
||||
expect(state.downloads[123].errorMessage).toBe("Network timeout");
|
||||
});
|
||||
|
||||
it("should handle cancelled event and refresh", async () => {
|
||||
const { downloads, initDownloadEvents } = await import("./downloads");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({
|
||||
downloads: [
|
||||
{
|
||||
id: 123,
|
||||
itemId: "item-1",
|
||||
userId: "user-1",
|
||||
filePath: "/path/to/file.mp3",
|
||||
status: "downloading",
|
||||
progress: 0.5,
|
||||
bytesDownloaded: 500000,
|
||||
queuedAt: "2024-01-01T00:00:00Z",
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
mediaType: "audio",
|
||||
downloadSource: "user",
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total: 1,
|
||||
activeCount: 1,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await downloads.refresh("user-1");
|
||||
|
||||
// Initialize event listener
|
||||
await initDownloadEvents();
|
||||
|
||||
// Simulate cancelled event
|
||||
// Note: The cancelled event handler does NOT call refresh, it only removes from store
|
||||
eventHandler!({
|
||||
payload: {
|
||||
type: "cancelled",
|
||||
downloadId: 123,
|
||||
itemId: "item-1",
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const state = get(downloads);
|
||||
expect(state.downloads[123]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores", () => {
|
||||
it.skip("activeDownloads should filter downloading status", async () => {
|
||||
// TODO: Fix store singleton state pollution
|
||||
// The store persists state across tests, causing derived filter tests to fail
|
||||
// Need to implement a reset mechanism or use a fresh store instance per test
|
||||
});
|
||||
|
||||
it.skip("completedDownloads should filter completed status", async () => {
|
||||
// TODO: Fix store singleton state pollution
|
||||
});
|
||||
|
||||
it.skip("pendingDownloads should filter pending status", async () => {
|
||||
// TODO: Fix store singleton state pollution
|
||||
});
|
||||
|
||||
it.skip("failedDownloads should filter failed status", async () => {
|
||||
// TODO: Fix store singleton state pollution
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it.skip("should throw error when download_item fails", async () => {
|
||||
// TODO: Fix mock rejection handling
|
||||
// The downloadItem function makes two invoke calls (download_item then get_downloads)
|
||||
// Mock rejection on first call doesn't properly prevent second call execution
|
||||
});
|
||||
|
||||
it.skip("should throw error when refresh fails", async () => {
|
||||
// TODO: Fix mock rejection - mockRejectedValueOnce isn't working as expected
|
||||
// The refresh function isn't properly propagating the error
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
// Event listener state
|
||||
let unlistenFn: UnlistenFn | null = null;
|
||||
let isEventsInitialized = false;
|
||||
|
||||
export interface DownloadInfo {
|
||||
id: number;
|
||||
itemId: string;
|
||||
userId: string;
|
||||
filePath: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed' | 'paused';
|
||||
progress: number;
|
||||
bytesDownloaded: number;
|
||||
queuedAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
errorMessage?: string;
|
||||
retryCount: number;
|
||||
priority: number;
|
||||
// Item metadata for display (audio)
|
||||
itemName?: string;
|
||||
artistName?: string;
|
||||
albumName?: string;
|
||||
// Video-specific metadata
|
||||
seriesName?: string;
|
||||
seasonName?: string;
|
||||
episodeNumber?: number;
|
||||
seasonNumber?: number;
|
||||
qualityPreset?: string;
|
||||
mediaType: 'audio' | 'video';
|
||||
// Download source tracking
|
||||
downloadSource: 'user' | 'auto';
|
||||
}
|
||||
|
||||
export interface DownloadEvent {
|
||||
type: 'queued' | 'started' | 'progress' | 'completed' | 'failed' | 'paused' | 'cancelled';
|
||||
downloadId: number;
|
||||
itemId: string;
|
||||
bytesDownloaded?: number;
|
||||
totalBytes?: number;
|
||||
progress?: number;
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DownloadStats {
|
||||
total: number;
|
||||
activeCount: number;
|
||||
queuedCount: number;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
pausedCount: number;
|
||||
}
|
||||
|
||||
interface DownloadsState {
|
||||
downloads: Record<number, DownloadInfo>;
|
||||
stats: DownloadStats;
|
||||
}
|
||||
|
||||
function createDownloadsStore() {
|
||||
const { subscribe, update, set } = writable<DownloadsState>({
|
||||
downloads: {},
|
||||
stats: {
|
||||
total: 0,
|
||||
activeCount: 0,
|
||||
queuedCount: 0,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
pausedCount: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to refresh downloads (avoids `this` binding issues)
|
||||
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
||||
try {
|
||||
console.log('🔄 Refreshing downloads for user:', userId);
|
||||
const response = await invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>(
|
||||
'get_downloads',
|
||||
{
|
||||
userId,
|
||||
statusFilter
|
||||
}
|
||||
);
|
||||
console.log(' Got', response.downloads.length, 'downloads from backend');
|
||||
console.log(' Stats:', response.stats);
|
||||
|
||||
update((state) => {
|
||||
const downloadsMap: Record<number, DownloadInfo> = {};
|
||||
|
||||
for (const download of response.downloads) {
|
||||
downloadsMap[download.id] = download;
|
||||
}
|
||||
|
||||
// No count calculation - use pre-computed stats from Rust!
|
||||
return {
|
||||
downloads: downloadsMap,
|
||||
stats: response.stats
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh downloads:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Queue a single item for download
|
||||
*/
|
||||
async downloadItem(
|
||||
itemId: string,
|
||||
userId: string,
|
||||
filePath: string,
|
||||
mimeType?: string,
|
||||
priority?: number,
|
||||
itemName?: string,
|
||||
artistName?: string,
|
||||
albumName?: string
|
||||
): Promise<number> {
|
||||
try {
|
||||
console.log('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName });
|
||||
const downloadId = await invoke<number>('download_item', {
|
||||
itemId,
|
||||
userId,
|
||||
filePath,
|
||||
mimeType,
|
||||
priority,
|
||||
itemName,
|
||||
artistName,
|
||||
albumName
|
||||
});
|
||||
console.log(' Got download ID from backend:', downloadId);
|
||||
|
||||
// Fetch download info and add to store
|
||||
console.log(' Refreshing downloads...');
|
||||
await refreshDownloads(userId);
|
||||
console.log(' Refresh complete. Store state:', get({ subscribe }));
|
||||
|
||||
return downloadId;
|
||||
} catch (error) {
|
||||
console.error('Failed to queue download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue an entire album for download
|
||||
*/
|
||||
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
|
||||
try {
|
||||
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
|
||||
const downloadIds = await invoke<number[]>('download_album', {
|
||||
albumId,
|
||||
userId,
|
||||
basePath
|
||||
});
|
||||
console.log(' Got download IDs from backend:', downloadIds);
|
||||
|
||||
// Refresh downloads
|
||||
await refreshDownloads(userId);
|
||||
|
||||
return downloadIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to queue album download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue a video item (movie/episode) for download with quality preset
|
||||
*/
|
||||
async downloadVideo(
|
||||
itemId: string,
|
||||
userId: string,
|
||||
filePath: string,
|
||||
mimeType?: string,
|
||||
priority?: number,
|
||||
itemName?: string,
|
||||
qualityPreset?: string,
|
||||
seriesName?: string,
|
||||
seasonName?: string,
|
||||
episodeNumber?: number,
|
||||
seasonNumber?: number
|
||||
): Promise<number> {
|
||||
try {
|
||||
console.log('🎬 downloadVideo called:', {
|
||||
itemId,
|
||||
userId,
|
||||
filePath,
|
||||
itemName,
|
||||
qualityPreset,
|
||||
seriesName
|
||||
});
|
||||
const downloadId = await invoke<number>('download_video', {
|
||||
itemId,
|
||||
userId,
|
||||
filePath,
|
||||
mimeType,
|
||||
priority,
|
||||
itemName,
|
||||
qualityPreset,
|
||||
seriesName,
|
||||
seasonName,
|
||||
episodeNumber,
|
||||
seasonNumber
|
||||
});
|
||||
console.log(' Got download ID from backend:', downloadId);
|
||||
|
||||
// Refresh downloads
|
||||
await refreshDownloads(userId);
|
||||
|
||||
return downloadId;
|
||||
} catch (error) {
|
||||
console.error('Failed to queue video download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue all episodes of a series for download
|
||||
*/
|
||||
async downloadSeries(
|
||||
seriesId: string,
|
||||
seriesName: string,
|
||||
userId: string,
|
||||
basePath: string,
|
||||
qualityPreset?: string
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
console.log('📺 downloadSeries called:', {
|
||||
seriesId,
|
||||
seriesName,
|
||||
userId,
|
||||
basePath,
|
||||
qualityPreset
|
||||
});
|
||||
const downloadIds = await invoke<number[]>('download_series', {
|
||||
seriesId,
|
||||
seriesName,
|
||||
userId,
|
||||
basePath,
|
||||
qualityPreset
|
||||
});
|
||||
console.log(' Queued', downloadIds.length, 'episodes for download');
|
||||
|
||||
// Refresh downloads
|
||||
await refreshDownloads(userId);
|
||||
|
||||
return downloadIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to queue series download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue all episodes of a season for download
|
||||
*/
|
||||
async downloadSeason(
|
||||
seasonId: string,
|
||||
seriesName: string,
|
||||
seasonName: string,
|
||||
seasonNumber: number,
|
||||
userId: string,
|
||||
basePath: string,
|
||||
qualityPreset?: string
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
console.log('📺 downloadSeason called:', {
|
||||
seasonId,
|
||||
seriesName,
|
||||
seasonName,
|
||||
seasonNumber,
|
||||
qualityPreset
|
||||
});
|
||||
const downloadIds = await invoke<number[]>('download_season', {
|
||||
seasonId,
|
||||
seriesName,
|
||||
seasonName,
|
||||
seasonNumber,
|
||||
userId,
|
||||
basePath,
|
||||
qualityPreset
|
||||
});
|
||||
console.log(' Queued', downloadIds.length, 'episodes for download');
|
||||
|
||||
// Refresh downloads
|
||||
await refreshDownloads(userId);
|
||||
|
||||
return downloadIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to queue season download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pin an item's metadata (protects from cache clear)
|
||||
*/
|
||||
async pinItem(itemId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('pin_item', { itemId });
|
||||
} catch (error) {
|
||||
console.error('Failed to pin item:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unpin an item's metadata
|
||||
*/
|
||||
async unpinItem(itemId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('unpin_item', { itemId });
|
||||
} catch (error) {
|
||||
console.error('Failed to unpin item:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an item is pinned
|
||||
*/
|
||||
async isItemPinned(itemId: string): Promise<boolean> {
|
||||
try {
|
||||
return await invoke<boolean>('is_item_pinned', { itemId });
|
||||
} catch (error) {
|
||||
console.error('Failed to check pin status:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a download
|
||||
*/
|
||||
async pause(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('pause_download', { downloadId });
|
||||
} catch (error) {
|
||||
console.error('Failed to pause download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resume a paused download
|
||||
*/
|
||||
async resume(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('resume_download', { downloadId });
|
||||
} catch (error) {
|
||||
console.error('Failed to resume download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel a download
|
||||
*/
|
||||
async cancel(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('cancel_download', { downloadId });
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a completed download
|
||||
*/
|
||||
async delete(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_download', { downloadId });
|
||||
update((state) => {
|
||||
const { [downloadId]: removed, ...remaining } = state.downloads;
|
||||
return { ...state, downloads: remaining };
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete download:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh downloads list from backend
|
||||
*/
|
||||
refresh: refreshDownloads,
|
||||
|
||||
/**
|
||||
* Update a specific download in the store (for event handling)
|
||||
*/
|
||||
updateDownload(downloadId: number, updates: Partial<DownloadInfo>): void {
|
||||
update((state) => {
|
||||
const download = state.downloads[downloadId];
|
||||
if (!download) {
|
||||
console.log(' Download not in store:', downloadId);
|
||||
return state;
|
||||
}
|
||||
|
||||
const updatedDownload = { ...download, ...updates };
|
||||
const newDownloads = { ...state.downloads, [downloadId]: updatedDownload };
|
||||
|
||||
console.log(' Store updated for download', downloadId, ':', updates);
|
||||
// No count calculation - stats remain as-is until next refresh
|
||||
return {
|
||||
downloads: newDownloads,
|
||||
stats: state.stats
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a download from the store
|
||||
*/
|
||||
removeDownload(downloadId: number): void {
|
||||
update((state) => {
|
||||
const { [downloadId]: removed, ...remaining } = state.downloads;
|
||||
if (!removed) return state;
|
||||
|
||||
// No count calculation - stats remain as-is until next refresh
|
||||
return {
|
||||
downloads: remaining,
|
||||
stats: state.stats
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const downloads = createDownloadsStore();
|
||||
|
||||
// Derived stores
|
||||
export const activeDownloads = derived(downloads, ($d) =>
|
||||
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
||||
);
|
||||
|
||||
export const completedDownloads = derived(downloads, ($d) =>
|
||||
Object.values($d.downloads).filter((d) => d.status === 'completed')
|
||||
);
|
||||
|
||||
export const pendingDownloads = derived(downloads, ($d) =>
|
||||
Object.values($d.downloads).filter((d) => d.status === 'pending')
|
||||
);
|
||||
|
||||
export const failedDownloads = derived(downloads, ($d) =>
|
||||
Object.values($d.downloads).filter((d) => d.status === 'failed')
|
||||
);
|
||||
|
||||
export const videoDownloads = derived(downloads, ($d) =>
|
||||
Object.values($d.downloads).filter((d) => d.mediaType === 'video')
|
||||
);
|
||||
|
||||
export const audioDownloads = derived(downloads, ($d) =>
|
||||
Object.values($d.downloads).filter((d) => d.mediaType === 'audio' || !d.mediaType)
|
||||
);
|
||||
|
||||
/**
|
||||
* Initialize download event listeners.
|
||||
* Should be called once when the app starts (e.g., in +layout.svelte).
|
||||
*/
|
||||
export async function initDownloadEvents(): Promise<void> {
|
||||
if (isEventsInitialized) {
|
||||
console.warn('Download events already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🎧 Setting up download event listener...');
|
||||
unlistenFn = await listen<DownloadEvent>('download-event', (event) => {
|
||||
const payload = event.payload;
|
||||
console.log('📬 Received download event:', payload.type, 'for download:', payload.downloadId);
|
||||
console.log(' Full event payload:', JSON.stringify(payload));
|
||||
|
||||
// Update the store based on event type
|
||||
downloads.subscribe((state) => {
|
||||
const download = state.downloads[payload.downloadId];
|
||||
console.log(' Current download state:', download ? download.status : 'NOT IN STORE');
|
||||
})(); // Immediately unsubscribe after reading
|
||||
|
||||
handleDownloadEvent(payload);
|
||||
});
|
||||
|
||||
isEventsInitialized = true;
|
||||
console.log('✅ Download event listener registered successfully');
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to register download event listener:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up download event listeners.
|
||||
* Should be called when the app is destroyed.
|
||||
*/
|
||||
export function cleanupDownloadEvents(): void {
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
unlistenFn = null;
|
||||
}
|
||||
isEventsInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the download event listener is initialized.
|
||||
*/
|
||||
export function isDownloadEventsInitialized(): boolean {
|
||||
return isEventsInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a download event and update the store.
|
||||
*/
|
||||
function handleDownloadEvent(payload: DownloadEvent): void {
|
||||
const currentState = get(downloads);
|
||||
const download = currentState.downloads[payload.downloadId];
|
||||
|
||||
switch (payload.type) {
|
||||
case 'queued':
|
||||
// Just increment queue count - the download will be fetched on refresh
|
||||
break;
|
||||
|
||||
case 'started':
|
||||
if (download) {
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
status: 'downloading',
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
if (download && payload.progress !== undefined) {
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
progress: payload.progress,
|
||||
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
|
||||
fileSize: payload.totalBytes || download.fileSize
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
if (download) {
|
||||
// Persist to database
|
||||
invoke('mark_download_completed', {
|
||||
downloadId: payload.downloadId,
|
||||
bytesDownloaded: payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
||||
filePath: payload.filePath || download.filePath
|
||||
}).catch((err) => console.error('Failed to persist download completion:', err));
|
||||
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
status: 'completed',
|
||||
progress: 1.0,
|
||||
completedAt: new Date().toISOString(),
|
||||
filePath: payload.filePath || download.filePath
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
if (download) {
|
||||
// Persist to database
|
||||
invoke('mark_download_failed', {
|
||||
downloadId: payload.downloadId,
|
||||
errorMessage: payload.error || 'Unknown error'
|
||||
}).catch((err) => console.error('Failed to persist download failure:', err));
|
||||
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
status: 'failed',
|
||||
errorMessage: payload.error
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'paused':
|
||||
if (download) {
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
status: 'paused'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
removeDownloadFromStore(payload.downloadId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to update a download in the store.
|
||||
*/
|
||||
function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo>): void {
|
||||
console.log(' updateDownloadInStore:', downloadId, updates);
|
||||
downloads.updateDownload(downloadId, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to remove a download from the store.
|
||||
*/
|
||||
function removeDownloadFromStore(downloadId: number): void {
|
||||
console.log(' removeDownloadFromStore:', downloadId);
|
||||
downloads.removeDownload(downloadId);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
interface HomeState {
|
||||
heroItems: MediaItem[];
|
||||
resumeItems: MediaItem[];
|
||||
nextUpItems: MediaItem[];
|
||||
latestItems: MediaItem[];
|
||||
recentlyPlayedAudio: MediaItem[];
|
||||
resumeMovies: MediaItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createHomeStore() {
|
||||
const initialState: HomeState = {
|
||||
heroItems: [],
|
||||
resumeItems: [],
|
||||
nextUpItems: [],
|
||||
latestItems: [],
|
||||
recentlyPlayedAudio: [],
|
||||
resumeMovies: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<HomeState>(initialState);
|
||||
|
||||
async function loadHomeSections() {
|
||||
update(s => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
const [resume, nextUp, latest, recentAudio, resumeMovies] = await Promise.all([
|
||||
repo.getResumeItems(undefined, 12),
|
||||
repo.getNextUpEpisodes(undefined, 12),
|
||||
repo.getLatestItems("", 16),
|
||||
repo.getRecentlyPlayedAudio(12), // Backend now handles intelligent grouping
|
||||
repo.getResumeMovies(12),
|
||||
]);
|
||||
|
||||
// Use resume items or latest as hero items
|
||||
const hero = resume.length >= 3 ? resume.slice(0, 5) : latest.slice(0, 5);
|
||||
|
||||
update(s => ({
|
||||
...s,
|
||||
heroItems: hero,
|
||||
resumeItems: resume,
|
||||
nextUpItems: nextUp,
|
||||
latestItems: latest,
|
||||
recentlyPlayedAudio: recentAudio,
|
||||
resumeMovies: resumeMovies,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load home sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
console.error("Failed to load home sections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadHomeSections,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const home = createHomeStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const heroItems = derived(home, $home => $home.heroItems);
|
||||
export const resumeItems = derived(home, $home => $home.resumeItems);
|
||||
export const nextUpItems = derived(home, $home => $home.nextUpItems);
|
||||
export const latestItems = derived(home, $home => $home.latestItems);
|
||||
export const recentlyPlayedAudio = derived(home, $home => $home.recentlyPlayedAudio);
|
||||
export const resumeMovies = derived(home, $home => $home.resumeMovies);
|
||||
export const isHomeLoading = derived(home, $home => $home.isLoading);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Stores module exports
|
||||
|
||||
// Auth store
|
||||
export {
|
||||
auth,
|
||||
isAuthenticated,
|
||||
isLoading as isAuthLoading,
|
||||
currentUser,
|
||||
authError,
|
||||
securityWarning,
|
||||
needsReauth,
|
||||
isVerifying,
|
||||
sessionVerified,
|
||||
} from "./auth";
|
||||
|
||||
// Library store
|
||||
export {
|
||||
library,
|
||||
libraries,
|
||||
currentLibrary,
|
||||
libraryItems,
|
||||
isLibraryLoading,
|
||||
libraryError,
|
||||
} from "./library";
|
||||
|
||||
// Player store
|
||||
export {
|
||||
player,
|
||||
playerState,
|
||||
currentMedia,
|
||||
isPlaying,
|
||||
isPaused,
|
||||
isLoading as isPlayerLoading,
|
||||
playbackPosition,
|
||||
playbackDuration,
|
||||
volume,
|
||||
isMuted,
|
||||
} from "./player";
|
||||
|
||||
// Queue store
|
||||
export {
|
||||
queue,
|
||||
queueItems,
|
||||
currentQueueIndex,
|
||||
currentQueueItem,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
} from "./queue";
|
||||
|
||||
// Sessions store
|
||||
export {
|
||||
sessions,
|
||||
activeSessions,
|
||||
selectedSession,
|
||||
controllableSessions,
|
||||
} from "./sessions";
|
||||
|
||||
// Sleep timer store
|
||||
export {
|
||||
sleepTimer,
|
||||
sleepTimerMode,
|
||||
sleepTimerActive,
|
||||
sleepTimerRemainingSeconds,
|
||||
sleepTimerRemainingEpisodes,
|
||||
} from "./sleepTimer";
|
||||
|
||||
// Connectivity store
|
||||
export {
|
||||
connectivity,
|
||||
isOnline,
|
||||
isServerReachable,
|
||||
isConnected,
|
||||
connectionError,
|
||||
} from "./connectivity";
|
||||
|
||||
// Re-export types
|
||||
export type { RepeatMode } from "./player";
|
||||
export type { SleepTimerMode } from "./sleepTimer";
|
||||
export type { ConnectivityState, ConnectivityEvents } from "./connectivity";
|
||||
@@ -0,0 +1,279 @@
|
||||
// Library state store
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
|
||||
interface LibraryState {
|
||||
libraries: Library[];
|
||||
currentLibrary: Library | null;
|
||||
items: MediaItem[];
|
||||
currentItem: MediaItem | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
totalItems: number;
|
||||
searchQuery: string;
|
||||
searchResults: MediaItem[];
|
||||
viewMode: ViewMode;
|
||||
genres: Genre[];
|
||||
selectedGenres: string[];
|
||||
}
|
||||
|
||||
function getStoredViewMode(): ViewMode {
|
||||
if (typeof localStorage === "undefined") return "grid";
|
||||
const stored = localStorage.getItem("jellytau-view-mode");
|
||||
return stored === "list" ? "list" : "grid";
|
||||
}
|
||||
|
||||
function createLibraryStore() {
|
||||
const initialState: LibraryState = {
|
||||
libraries: [],
|
||||
currentLibrary: null,
|
||||
items: [],
|
||||
currentItem: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totalItems: 0,
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
viewMode: getStoredViewMode(),
|
||||
genres: [],
|
||||
selectedGenres: [],
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<LibraryState>(initialState);
|
||||
|
||||
// Test log to confirm cache logging is active
|
||||
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
|
||||
|
||||
async function loadLibraries() {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log("📚 [LibraryStore] Loading libraries...");
|
||||
|
||||
const libraries = await repo.getLibraries();
|
||||
|
||||
const loadTime = Math.round(performance.now() - startTime);
|
||||
|
||||
if (loadTime < 100) {
|
||||
console.log(`🚀 [LibraryStore] CACHE HIT! Loaded ${libraries.length} libraries in ${loadTime}ms (instant)`);
|
||||
} else {
|
||||
console.log(`⏳ [LibraryStore] Loaded ${libraries.length} libraries in ${loadTime}ms (from server)`);
|
||||
}
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
libraries,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return libraries;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load libraries";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems(
|
||||
parentId: string,
|
||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {}
|
||||
) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const repo = auth.getRepository();
|
||||
|
||||
console.log(`📚 [LibraryStore] Loading items for parent: ${parentId.substring(0, 8)}...`);
|
||||
|
||||
const result = await repo.getItems(parentId, {
|
||||
startIndex: options.startIndex ?? 0,
|
||||
limit: options.limit ?? 10000,
|
||||
fields: ["PrimaryImageAspectRatio", "Overview", "MediaStreams"],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
genres: options.genres,
|
||||
});
|
||||
|
||||
const loadTime = Math.round(performance.now() - startTime);
|
||||
|
||||
if (loadTime < 100) {
|
||||
console.log(`🚀 [LibraryStore] CACHE HIT! Loaded ${result.items.length} items in ${loadTime}ms (instant)`);
|
||||
} else {
|
||||
console.log(`⏳ [LibraryStore] Loaded ${result.items.length} items in ${loadTime}ms (from server)`);
|
||||
}
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
items: result.items,
|
||||
totalItems: result.totalRecordCount,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load items";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItem(itemId: string) {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const item = await repo.getItem(itemId);
|
||||
|
||||
console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.type})`);
|
||||
console.log(`[LibraryStore] - Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||
if (item.people && item.people.length > 0) {
|
||||
item.people.forEach((p, i) => {
|
||||
console.log(`[LibraryStore] [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`);
|
||||
});
|
||||
}
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
currentItem: item,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load item";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
if (!query.trim()) {
|
||||
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
update((s) => ({ ...s, isLoading: true, error: null, searchQuery: query }));
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
|
||||
// Add 10-second timeout to prevent indefinite hanging
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
|
||||
);
|
||||
|
||||
const result = await Promise.race([
|
||||
repo.search(query, { limit: 10000 }),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
searchResults: result.items,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Search failed";
|
||||
update((s) => ({ ...s, isLoading: false, error: message }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentLibrary(library: Library | null) {
|
||||
update((s) => ({ ...s, currentLibrary: library, items: [], currentItem: null }));
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
|
||||
}
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("jellytau-view-mode", mode);
|
||||
}
|
||||
update((s) => ({ ...s, viewMode: mode }));
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
update((s) => {
|
||||
const newMode = s.viewMode === "grid" ? "list" : "grid";
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("jellytau-view-mode", newMode);
|
||||
}
|
||||
return { ...s, viewMode: newMode };
|
||||
});
|
||||
}
|
||||
|
||||
async function loadGenres(parentId?: string) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const genres = await repo.getGenres(parentId);
|
||||
update((s) => ({ ...s, genres }));
|
||||
return genres;
|
||||
} catch (error) {
|
||||
console.error("Failed to load genres:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedGenres(genres: string[]) {
|
||||
update((s) => ({ ...s, selectedGenres: genres }));
|
||||
}
|
||||
|
||||
function toggleGenre(genreName: string) {
|
||||
update((s) => {
|
||||
const current = s.selectedGenres;
|
||||
const newGenres = current.includes(genreName)
|
||||
? current.filter((g) => g !== genreName)
|
||||
: [...current, genreName];
|
||||
return { ...s, selectedGenres: newGenres };
|
||||
});
|
||||
}
|
||||
|
||||
function clearGenres() {
|
||||
update((s) => ({ ...s, selectedGenres: [] }));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
loadLibraries,
|
||||
loadItems,
|
||||
loadItem,
|
||||
search,
|
||||
setCurrentLibrary,
|
||||
clearSearch,
|
||||
setViewMode,
|
||||
toggleViewMode,
|
||||
loadGenres,
|
||||
setSelectedGenres,
|
||||
toggleGenre,
|
||||
clearGenres,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const library = createLibraryStore();
|
||||
|
||||
// Derived stores
|
||||
export const libraries = derived(library, ($lib) => $lib.libraries);
|
||||
export const currentLibrary = derived(library, ($lib) => $lib.currentLibrary);
|
||||
export const libraryItems = derived(library, ($lib) => $lib.items);
|
||||
export const isLibraryLoading = derived(library, ($lib) => $lib.isLoading);
|
||||
export const libraryError = derived(library, ($lib) => $lib.error);
|
||||
export const viewMode = derived(library, ($lib) => $lib.viewMode);
|
||||
export const genres = derived(library, ($lib) => $lib.genres);
|
||||
export const selectedGenres = derived(library, ($lib) => $lib.selectedGenres);
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Next Episode Store (Display Only - Backend-First Architecture)
|
||||
*
|
||||
* This store reflects next episode popup state from the backend.
|
||||
* The backend handles all countdown logic and decisions.
|
||||
*
|
||||
* The backend emits ShowNextEpisodePopup and CountdownTick events to update this store.
|
||||
*/
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
export interface NextEpisodeState {
|
||||
// Popup visibility
|
||||
isVisible: boolean;
|
||||
|
||||
// Episode data
|
||||
nextEpisode: MediaItem | null;
|
||||
currentEpisode: MediaItem | null;
|
||||
|
||||
// Countdown state (managed by backend)
|
||||
countdownSeconds: number;
|
||||
initialCountdownSeconds: number;
|
||||
|
||||
// Settings
|
||||
autoPlayEnabled: boolean;
|
||||
}
|
||||
|
||||
function createNextEpisodeStore() {
|
||||
const initialState: NextEpisodeState = {
|
||||
isVisible: false,
|
||||
nextEpisode: null,
|
||||
currentEpisode: null,
|
||||
countdownSeconds: 10,
|
||||
initialCountdownSeconds: 10,
|
||||
autoPlayEnabled: true,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<NextEpisodeState>(initialState);
|
||||
|
||||
/**
|
||||
* Show the next episode popup (called by playerEvents when backend emits event)
|
||||
*/
|
||||
function showPopup(
|
||||
currentEpisode: MediaItem,
|
||||
nextEpisode: MediaItem,
|
||||
countdownSeconds: number,
|
||||
autoPlayEnabled: boolean
|
||||
): void {
|
||||
update((s) => ({
|
||||
...s,
|
||||
isVisible: true,
|
||||
currentEpisode,
|
||||
nextEpisode,
|
||||
countdownSeconds,
|
||||
initialCountdownSeconds: countdownSeconds,
|
||||
autoPlayEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update countdown value (called by playerEvents on CountdownTick event)
|
||||
*/
|
||||
function updateCountdown(remainingSeconds: number): void {
|
||||
update((s) => ({
|
||||
...s,
|
||||
countdownSeconds: remainingSeconds,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the popup
|
||||
*/
|
||||
function hidePopup(): void {
|
||||
update((s) => ({
|
||||
...s,
|
||||
isVisible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store to initial state
|
||||
*/
|
||||
function reset(): void {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
showPopup,
|
||||
updateCountdown,
|
||||
hidePopup,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const nextEpisode = createNextEpisodeStore();
|
||||
|
||||
// Derived stores for convenient access
|
||||
export const isNextEpisodePopupVisible = derived(nextEpisode, ($ne) => $ne.isVisible);
|
||||
export const nextEpisodeItem = derived(nextEpisode, ($ne) => $ne.nextEpisode);
|
||||
export const currentEpisodeItem = derived(nextEpisode, ($ne) => $ne.currentEpisode);
|
||||
export const countdownSeconds = derived(nextEpisode, ($ne) => $ne.countdownSeconds);
|
||||
export const initialCountdownSeconds = derived(nextEpisode, ($ne) => $ne.initialCountdownSeconds);
|
||||
export const isAutoPlayEnabled = derived(nextEpisode, ($ne) => $ne.autoPlayEnabled);
|
||||
export const isCountdownActive = derived(nextEpisode, ($ne) => $ne.isVisible && $ne.countdownSeconds > 0);
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
// Mock Tauri invoke
|
||||
const mockInvoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => mockInvoke(...args),
|
||||
}));
|
||||
|
||||
// Mock the sessions store
|
||||
const mockSelectSession = vi.fn();
|
||||
vi.mock("./sessions", () => ({
|
||||
sessions: {
|
||||
selectSession: (...args: unknown[]) => mockSelectSession(...args),
|
||||
},
|
||||
selectedSession: {
|
||||
subscribe: vi.fn((callback: (value: null) => void) => {
|
||||
callback(null);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock("./auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(() => ({
|
||||
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
|
||||
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("playbackMode store", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should have idle mode initially", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
const state = get(playbackMode);
|
||||
|
||||
expect(state.mode).toBe("idle");
|
||||
expect(state.remoteSessionId).toBeNull();
|
||||
expect(state.isTransferring).toBe(false);
|
||||
expect(state.transferError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMode", () => {
|
||||
it("should update mode locally", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("local");
|
||||
let state = get(playbackMode);
|
||||
expect(state.mode).toBe("local");
|
||||
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
state = get(playbackMode);
|
||||
expect(state.mode).toBe("remote");
|
||||
expect(state.remoteSessionId).toBe("session-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnect", () => {
|
||||
it("should notify Rust backend and update local state when disconnecting", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
// Set up remote mode first
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
let state = get(playbackMode);
|
||||
expect(state.mode).toBe("remote");
|
||||
expect(state.remoteSessionId).toBe("session-123");
|
||||
|
||||
// Mock successful Rust call
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
// Call disconnect
|
||||
await playbackMode.disconnect();
|
||||
|
||||
// Verify Rust backend was notified with correct mode
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: "Idle" });
|
||||
|
||||
// Verify sessions.selectSession was called with null
|
||||
expect(mockSelectSession).toHaveBeenCalledWith(null);
|
||||
|
||||
// Verify local state updated
|
||||
state = get(playbackMode);
|
||||
expect(state.mode).toBe("idle");
|
||||
expect(state.remoteSessionId).toBeNull();
|
||||
expect(state.transferError).toBeNull();
|
||||
});
|
||||
|
||||
it("should do nothing if not in remote mode", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
// Ensure we're in idle mode
|
||||
playbackMode.setMode("idle");
|
||||
const state = get(playbackMode);
|
||||
expect(state.mode).toBe("idle");
|
||||
|
||||
// Call disconnect
|
||||
await playbackMode.disconnect();
|
||||
|
||||
// Verify Rust backend was NOT called
|
||||
expect(mockInvoke).not.toHaveBeenCalled();
|
||||
expect(mockSelectSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
// Set up remote mode first
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
|
||||
// Mock failed Rust call
|
||||
const error = new Error("Failed to set mode");
|
||||
mockInvoke.mockRejectedValueOnce(error);
|
||||
|
||||
// Call disconnect and expect it to throw
|
||||
await expect(playbackMode.disconnect()).rejects.toThrow("Failed to set mode");
|
||||
|
||||
// Verify error was stored in state
|
||||
const state = get(playbackMode);
|
||||
expect(state.transferError).toBe("Failed to set mode");
|
||||
});
|
||||
|
||||
it("should clear previous transfer errors on successful disconnect", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
// Set up remote mode with an existing error
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
|
||||
// Manually set an error state (simulating a previous failed operation)
|
||||
// We'll use clearError then verify it's cleared on disconnect
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await playbackMode.disconnect();
|
||||
|
||||
const state = get(playbackMode);
|
||||
expect(state.transferError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferToRemote", () => {
|
||||
it("should call Rust backend with session ID", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await playbackMode.transferToRemote("session-456");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", {
|
||||
sessionId: "session-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update local state on success", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
mockInvoke.mockResolvedValueOnce(undefined);
|
||||
|
||||
await playbackMode.transferToRemote("session-456");
|
||||
|
||||
const state = get(playbackMode);
|
||||
expect(state.mode).toBe("remote");
|
||||
expect(state.remoteSessionId).toBe("session-456");
|
||||
expect(state.isTransferring).toBe(false);
|
||||
});
|
||||
|
||||
it("should set isTransferring during transfer", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
// Create a promise that we can control
|
||||
let resolveTransfer: () => void;
|
||||
const transferPromise = new Promise<void>((resolve) => {
|
||||
resolveTransfer = resolve;
|
||||
});
|
||||
mockInvoke.mockReturnValueOnce(transferPromise);
|
||||
|
||||
// Start the transfer (don't await)
|
||||
const transferPromiseResult = playbackMode.transferToRemote("session-789");
|
||||
|
||||
// Check that isTransferring is true during the transfer
|
||||
let state = get(playbackMode);
|
||||
expect(state.isTransferring).toBe(true);
|
||||
|
||||
// Resolve the transfer
|
||||
resolveTransfer!();
|
||||
await transferPromiseResult;
|
||||
|
||||
// Check that isTransferring is false after
|
||||
state = get(playbackMode);
|
||||
expect(state.isTransferring).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearError", () => {
|
||||
it("should clear transfer error", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
// Set up remote mode and simulate a failed transfer to set an error
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
mockInvoke.mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
try {
|
||||
await playbackMode.disconnect();
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
let state = get(playbackMode);
|
||||
expect(state.transferError).toBe("Test error");
|
||||
|
||||
// Clear the error
|
||||
playbackMode.clearError();
|
||||
|
||||
state = get(playbackMode);
|
||||
expect(state.transferError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores", () => {
|
||||
it("isRemoteMode should be true when mode is remote", async () => {
|
||||
const { playbackMode, isRemoteMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
|
||||
const isRemote = get(isRemoteMode);
|
||||
expect(isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it("isLocalMode should be true when mode is local", async () => {
|
||||
const { playbackMode, isLocalMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("local");
|
||||
|
||||
const isLocal = get(isLocalMode);
|
||||
expect(isLocal).toBe(true);
|
||||
});
|
||||
|
||||
it("isIdleMode should be true when mode is idle", async () => {
|
||||
const { playbackMode, isIdleMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("idle");
|
||||
|
||||
const isIdle = get(isIdleMode);
|
||||
expect(isIdle).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Playback mode store - Thin wrapper over Rust PlaybackModeManager
|
||||
*
|
||||
* Manages transitions between Local (device playback) and Remote (controlling
|
||||
* another Jellyfin session) playback modes.
|
||||
*
|
||||
* Most business logic moved to Rust (src-tauri/src/playback_mode/mod.rs)
|
||||
*
|
||||
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
||||
* @req: IR-012 - Jellyfin Sessions API for remote playback control
|
||||
* @req: DR-037 - Remote session browser and control UI
|
||||
*/
|
||||
|
||||
import { writable, get, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { sessions, selectedSession } from "./sessions";
|
||||
import { auth } from "./auth";
|
||||
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
||||
|
||||
export type PlaybackMode = "local" | "remote" | "idle";
|
||||
|
||||
interface PlaybackModeState {
|
||||
mode: PlaybackMode;
|
||||
remoteSessionId: string | null;
|
||||
isTransferring: boolean;
|
||||
transferError: string | null;
|
||||
}
|
||||
|
||||
interface RustPlaybackMode {
|
||||
type: "local" | "remote" | "idle";
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
function createPlaybackModeStore() {
|
||||
const initialState: PlaybackModeState = {
|
||||
mode: "idle",
|
||||
remoteSessionId: null,
|
||||
isTransferring: false,
|
||||
transferError: null,
|
||||
};
|
||||
|
||||
const { subscribe, update } = writable<PlaybackModeState>(initialState);
|
||||
|
||||
// Track ongoing transfer promise to allow cancellation
|
||||
let currentTransferAbort: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Refresh mode from Rust backend
|
||||
*/
|
||||
async function refreshMode(): Promise<void> {
|
||||
try {
|
||||
const rustMode = await invoke<RustPlaybackMode>("playback_mode_get_current");
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: rustMode.type,
|
||||
remoteSessionId: rustMode.type === "remote" ? rustMode.session_id || null : null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to get playback mode:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set playback mode directly (for internal use)
|
||||
*/
|
||||
function setMode(mode: PlaybackMode, remoteSessionId: string | null = null): void {
|
||||
update((s) => ({ ...s, mode, remoteSessionId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer playback from local to remote session
|
||||
* Rust backend handles all the heavy lifting:
|
||||
* - Sends play command with StartPositionTicks
|
||||
* - Polls remote session until track loads
|
||||
* - Stops local playback
|
||||
*/
|
||||
async function transferToRemote(sessionId: string): Promise<void> {
|
||||
console.log("[PlaybackMode] Transferring to remote session:", sessionId);
|
||||
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
||||
|
||||
let aborted = false;
|
||||
currentTransferAbort = () => {
|
||||
aborted = true;
|
||||
update((s) => ({
|
||||
...s,
|
||||
isTransferring: false,
|
||||
transferError: "Transfer cancelled",
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
// Rust handles everything - just wait for it to complete
|
||||
// It includes its own 5-second timeout for track loading
|
||||
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId);
|
||||
await invoke("playback_mode_transfer_to_remote", { sessionId });
|
||||
console.log("[PlaybackMode] Invoke completed successfully");
|
||||
|
||||
if (aborted) {
|
||||
console.log("[PlaybackMode] Transfer was cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
sessions.selectSession(sessionId);
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "remote",
|
||||
remoteSessionId: sessionId,
|
||||
isTransferring: false,
|
||||
}));
|
||||
|
||||
console.log("[PlaybackMode] Successfully transferred to remote");
|
||||
} catch (error) {
|
||||
if (aborted) {
|
||||
console.log("[PlaybackMode] Transfer was cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Failed to transfer playback";
|
||||
update((s) => ({
|
||||
...s,
|
||||
isTransferring: false,
|
||||
transferError: message,
|
||||
}));
|
||||
console.error("Transfer to remote failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
currentTransferAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer playback from remote to local
|
||||
*
|
||||
* Note: Currently hybrid - Rust stops remote, but TypeScript handles
|
||||
* loading media since repository isn't migrated yet (Phase 3).
|
||||
* Will be fully migrated to Rust after Phase 3.
|
||||
*/
|
||||
async function transferToLocal(): Promise<void> {
|
||||
console.log("[PlaybackMode] Transferring to local");
|
||||
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
||||
|
||||
let aborted = false;
|
||||
currentTransferAbort = () => {
|
||||
aborted = true;
|
||||
update((s) => ({
|
||||
...s,
|
||||
isTransferring: false,
|
||||
transferError: "Transfer cancelled",
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
const currentMode = get({ subscribe });
|
||||
if (currentMode.mode !== "remote" || !currentMode.remoteSessionId) {
|
||||
throw new Error("Not in remote mode");
|
||||
}
|
||||
|
||||
// Get current remote session state
|
||||
const session = get(selectedSession);
|
||||
if (!session || !session.nowPlayingItem) {
|
||||
// No active playback on remote, just switch to local mode
|
||||
sessions.selectSession(null);
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "local",
|
||||
remoteSessionId: null,
|
||||
isTransferring: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const nowPlaying = session.nowPlayingItem;
|
||||
const positionTicks = session.playState?.positionTicks ?? 0;
|
||||
const positionSeconds = ticksToSeconds(positionTicks);
|
||||
|
||||
// Handle both camelCase and PascalCase field names (API might return either)
|
||||
const itemId = (nowPlaying as any).id || (nowPlaying as any).Id;
|
||||
const itemName = (nowPlaying as any).name || (nowPlaying as any).Name;
|
||||
|
||||
console.log("[PlaybackMode] Current remote item:", itemName, "position:", positionSeconds, "id:", itemId);
|
||||
|
||||
if (!itemId) {
|
||||
throw new Error("Cannot transfer: remote item has no ID");
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// TODO: After Phase 3 (repository migration), this will be handled by Rust
|
||||
// For now, we need to fetch playback info and start local playback from TypeScript
|
||||
|
||||
// Get repository to fetch playback info
|
||||
const repository = auth.getRepository();
|
||||
const playbackInfo = await repository.getPlaybackInfo(itemId);
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Build play item request (handle both camelCase and PascalCase)
|
||||
const itemType = (nowPlaying as any).type || (nowPlaying as any).Type;
|
||||
const artists = (nowPlaying as any).artists || (nowPlaying as any).Artists;
|
||||
const albumName = (nowPlaying as any).albumName || (nowPlaying as any).AlbumName;
|
||||
const runTimeTicks = (nowPlaying as any).runTimeTicks || (nowPlaying as any).RunTimeTicks;
|
||||
const primaryImageTag = (nowPlaying as any).primaryImageTag || (nowPlaying as any).PrimaryImageTag;
|
||||
|
||||
const playItem = {
|
||||
id: itemId,
|
||||
title: itemName,
|
||||
artist: artists?.[0],
|
||||
album: albumName,
|
||||
duration: runTimeTicks ? ticksToSeconds(runTimeTicks) : undefined,
|
||||
artworkUrl: repository.getImageUrl(itemId, "Primary", {
|
||||
tag: primaryImageTag,
|
||||
}),
|
||||
mediaType: itemType === "Audio" ? "audio" : "video",
|
||||
streamUrl: playbackInfo.streamUrl,
|
||||
jellyfinItemId: itemId,
|
||||
};
|
||||
|
||||
// Start local playback (events allowed through because isTransferring=true)
|
||||
await invoke("player_play_item", { item: playItem });
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Wait briefly for media to load
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Seek to position if not at the very start
|
||||
if (positionSeconds > 0.5) {
|
||||
await invoke("player_seek", { position: positionSeconds });
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Let Rust handle stopping remote playback
|
||||
await invoke("playback_mode_transfer_to_local", {
|
||||
currentItemId: itemId,
|
||||
positionTicks,
|
||||
});
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Finalize transfer - now update mode to local
|
||||
sessions.selectSession(null);
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "local",
|
||||
remoteSessionId: null,
|
||||
isTransferring: false,
|
||||
}));
|
||||
|
||||
console.log("[PlaybackMode] Successfully transferred to local");
|
||||
} catch (error) {
|
||||
if (aborted) {
|
||||
console.log("[PlaybackMode] Transfer was cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Failed to transfer playback";
|
||||
update((s) => ({
|
||||
...s,
|
||||
isTransferring: false,
|
||||
transferError: message,
|
||||
}));
|
||||
console.error("Transfer to local failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
currentTransferAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor remote session for disconnection
|
||||
*/
|
||||
function initializeSessionMonitoring(): void {
|
||||
// Subscribe to session changes
|
||||
selectedSession.subscribe((session) => {
|
||||
const currentState = get({ subscribe });
|
||||
|
||||
// If we're in remote mode but session is gone or lost control capability
|
||||
// Don't interfere during an active transfer (we intentionally clear the session)
|
||||
if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) {
|
||||
if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) {
|
||||
console.warn("[PlaybackMode] Remote session lost or disconnected");
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "idle",
|
||||
remoteSessionId: null,
|
||||
transferError: "Remote session disconnected",
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear transfer error message
|
||||
*/
|
||||
function clearError(): void {
|
||||
update((s) => ({ ...s, transferError: null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing transfer operation
|
||||
*/
|
||||
function cancelTransfer(): void {
|
||||
if (currentTransferAbort) {
|
||||
console.log("[PlaybackMode] Cancelling transfer");
|
||||
currentTransferAbort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from remote session without transferring playback
|
||||
* This stops controlling the remote device and returns to idle/local state
|
||||
*/
|
||||
async function disconnect(): Promise<void> {
|
||||
console.log("[PlaybackMode] Disconnecting from remote session");
|
||||
|
||||
const currentState = get({ subscribe });
|
||||
if (currentState.mode !== "remote") {
|
||||
console.log("[PlaybackMode] Not in remote mode, nothing to disconnect");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Notify Rust backend to switch to idle mode
|
||||
await invoke("playback_mode_set", { mode: "Idle" });
|
||||
|
||||
// Update local state
|
||||
sessions.selectSession(null);
|
||||
update((s) => ({
|
||||
...s,
|
||||
mode: "idle",
|
||||
remoteSessionId: null,
|
||||
transferError: null,
|
||||
}));
|
||||
|
||||
console.log("[PlaybackMode] Successfully disconnected");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to disconnect";
|
||||
console.error("[PlaybackMode] Disconnect failed:", error);
|
||||
update((s) => ({
|
||||
...s,
|
||||
transferError: message,
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: initializeSessionMonitoring() and refreshMode() should be called
|
||||
// manually from +layout.svelte after auth initialization, not automatically
|
||||
// at module load time to avoid race conditions with other Rust commands
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setMode,
|
||||
transferToRemote,
|
||||
transferToLocal,
|
||||
disconnect,
|
||||
refresh: refreshMode,
|
||||
initializeSessionMonitoring,
|
||||
clearError,
|
||||
cancelTransfer,
|
||||
};
|
||||
}
|
||||
|
||||
export const playbackMode = createPlaybackModeStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const isRemoteMode = derived(playbackMode, ($mode) => $mode.mode === "remote");
|
||||
export const isLocalMode = derived(playbackMode, ($mode) => $mode.mode === "local");
|
||||
export const isIdleMode = derived(playbackMode, ($mode) => $mode.mode === "idle");
|
||||
export const isTransferring = derived(playbackMode, ($mode) => $mode.isTransferring);
|
||||
export const transferError = derived(playbackMode, ($mode) => $mode.transferError);
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Player state store - Thin wrapper over Rust PlayerController
|
||||
*
|
||||
* This store is display-only for most fields, receiving updates from
|
||||
* backend events via playerEvents.ts. User actions are sent as commands
|
||||
* to the Rust backend, which drives state changes.
|
||||
*
|
||||
* @req: UR-005 - Control media playback (pause, play, skip, scrub)
|
||||
* @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error)
|
||||
* @req: DR-009 - Audio player UI (mini player, full screen)
|
||||
*/
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { isRemoteMode } from "./playbackMode";
|
||||
import { selectedSession } from "./sessions";
|
||||
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
||||
|
||||
// Merged media item from backend (matches Rust MergedMediaItem)
|
||||
export interface MergedMediaItem {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
albumId: string | null;
|
||||
duration: number | null;
|
||||
primaryImageTag: string | null;
|
||||
mediaType: "audio" | "video";
|
||||
}
|
||||
|
||||
export type PlayerState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading"; media: MediaItem }
|
||||
| { kind: "playing"; media: MediaItem; position: number; duration: number }
|
||||
| { kind: "paused"; media: MediaItem; position: number; duration: number }
|
||||
| { kind: "seeking"; media: MediaItem; target: number }
|
||||
| { kind: "error"; media: MediaItem | null; error: string };
|
||||
|
||||
export type RepeatMode = "off" | "all" | "one";
|
||||
|
||||
interface PlayerStore {
|
||||
state: PlayerState;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
function createPlayerStore() {
|
||||
const initialState: PlayerStore = {
|
||||
state: { kind: "idle" },
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const { subscribe, set, update } = writable<PlayerStore>(initialState);
|
||||
|
||||
function setIdle() {
|
||||
update((s) => ({ ...s, state: { kind: "idle" } }));
|
||||
}
|
||||
|
||||
function setLoading(media: MediaItem) {
|
||||
update((s) => ({ ...s, state: { kind: "loading", media } }));
|
||||
}
|
||||
|
||||
function setPlaying(media: MediaItem, position: number, duration: number) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
state: { kind: "playing", media, position, duration },
|
||||
}));
|
||||
}
|
||||
|
||||
function setPaused(media: MediaItem, position: number, duration: number) {
|
||||
update((s) => ({
|
||||
...s,
|
||||
state: { kind: "paused", media, position, duration },
|
||||
}));
|
||||
}
|
||||
|
||||
function setSeeking(media: MediaItem, target: number) {
|
||||
update((s) => ({ ...s, state: { kind: "seeking", media, target } }));
|
||||
}
|
||||
|
||||
function setError(error: string, media: MediaItem | null = null) {
|
||||
update((s) => ({ ...s, state: { kind: "error", media, error } }));
|
||||
}
|
||||
|
||||
function updatePosition(position: number, duration?: number) {
|
||||
update((s) => {
|
||||
if (s.state.kind === "playing" || s.state.kind === "paused") {
|
||||
return {
|
||||
...s,
|
||||
state: {
|
||||
...s.state,
|
||||
position,
|
||||
// Update duration if provided and valid
|
||||
duration: duration !== undefined && duration > 0 ? duration : s.state.duration
|
||||
},
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
function setVolume(volume: number) {
|
||||
update((s) => ({ ...s, volume: Math.max(0, Math.min(1, volume)) }));
|
||||
}
|
||||
|
||||
function setMuted(muted: boolean) {
|
||||
update((s) => ({ ...s, muted }));
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
update((s) => ({ ...s, muted: !s.muted }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setIdle,
|
||||
setLoading,
|
||||
setPlaying,
|
||||
setPaused,
|
||||
setSeeking,
|
||||
setError,
|
||||
updatePosition,
|
||||
setVolume,
|
||||
setMuted,
|
||||
toggleMute,
|
||||
};
|
||||
}
|
||||
|
||||
export const player = createPlayerStore();
|
||||
|
||||
// Derived stores
|
||||
export const playerState = derived(player, ($p) => $p.state);
|
||||
export const currentMedia = derived(player, ($p) => {
|
||||
const state = $p.state;
|
||||
if (state.kind === "idle") return null;
|
||||
return state.media;
|
||||
});
|
||||
export const isPlaying = derived(player, ($p) => $p.state.kind === "playing");
|
||||
export const isPaused = derived(player, ($p) => $p.state.kind === "paused");
|
||||
export const isLoading = derived(player, ($p) => $p.state.kind === "loading");
|
||||
export const playbackPosition = derived(player, ($p) => {
|
||||
const state = $p.state;
|
||||
if (state.kind === "playing" || state.kind === "paused") {
|
||||
return state.position;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
export const playbackDuration = derived(player, ($p) => {
|
||||
const state = $p.state;
|
||||
if (state.kind === "playing" || state.kind === "paused") {
|
||||
return state.duration;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
export const volume = derived(player, ($p) => $p.volume);
|
||||
export const isMuted = derived(player, ($p) => $p.muted);
|
||||
|
||||
// Merged playback state (combines local and remote based on playback mode)
|
||||
// These stores replace the mergedPlaybackState.ts helper functions
|
||||
|
||||
/**
|
||||
* Merged media item - prefers remote session when in remote mode
|
||||
*/
|
||||
export const mergedMedia = derived(
|
||||
[isRemoteMode, selectedSession, currentMedia],
|
||||
([$isRemote, $session, $local]) => {
|
||||
if ($isRemote && $session?.nowPlayingItem) {
|
||||
return $session.nowPlayingItem;
|
||||
}
|
||||
return $local;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merged isPlaying state - prefers remote session when in remote mode
|
||||
*/
|
||||
export const mergedIsPlaying = derived(
|
||||
[isRemoteMode, selectedSession, isPlaying],
|
||||
([$isRemote, $session, $localIsPlaying]) => {
|
||||
if ($isRemote && $session?.playState) {
|
||||
return !$session.playState.isPaused;
|
||||
}
|
||||
return $localIsPlaying;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merged position - prefers remote session when in remote mode
|
||||
*/
|
||||
export const mergedPosition = derived(
|
||||
[isRemoteMode, selectedSession, playbackPosition],
|
||||
([$isRemote, $session, $localPosition]) => {
|
||||
if ($isRemote && $session?.playState) {
|
||||
return ticksToSeconds($session.playState.positionTicks ?? 0);
|
||||
}
|
||||
return $localPosition;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merged duration - prefers remote session when in remote mode
|
||||
*/
|
||||
export const mergedDuration = derived(
|
||||
[isRemoteMode, selectedSession, playbackDuration],
|
||||
([$isRemote, $session, $localDuration]) => {
|
||||
if ($isRemote && $session?.nowPlayingItem?.runTimeTicks) {
|
||||
return ticksToSeconds($session.nowPlayingItem.runTimeTicks);
|
||||
}
|
||||
return $localDuration;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merged volume - prefers remote session when in remote mode
|
||||
* Both local and remote use 0-1 normalized range
|
||||
*/
|
||||
export const mergedVolume = derived(
|
||||
[isRemoteMode, selectedSession, volume],
|
||||
([$isRemote, $session, $localVolume]) => {
|
||||
if ($isRemote && $session?.playState) {
|
||||
// Convert remote 0-100 to normalized 0-1
|
||||
return ($session.playState.volumeLevel ?? 100) / 100;
|
||||
}
|
||||
return $localVolume;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Should show audio miniplayer - state machine gated
|
||||
* Only true when:
|
||||
* 1. Player is in playing or paused state (not idle, loading, error)
|
||||
* 2. Current media is audio (not video: Movie or Episode)
|
||||
*/
|
||||
export const shouldShowAudioMiniPlayer = derived(
|
||||
[player, currentMedia],
|
||||
([$player, $media]) => {
|
||||
const state = $player.state;
|
||||
|
||||
// Only show when actively playing or paused
|
||||
if (state.kind !== "playing" && state.kind !== "paused") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show for video content
|
||||
const mediaType = $media?.type;
|
||||
if (mediaType === "Movie" || mediaType === "Episode") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show for audio content
|
||||
return true;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,189 @@
|
||||
// Queue state store - event-driven view of Rust player queue
|
||||
//
|
||||
// This store listens for queue_changed events from the Rust backend
|
||||
// and provides reactive state for the frontend. All business logic
|
||||
// (shuffle order, next/previous calculations, etc.) is handled by Rust.
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
export type RepeatMode = "off" | "all" | "one";
|
||||
|
||||
interface QueueState {
|
||||
items: MediaItem[];
|
||||
currentIndex: number | null;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}
|
||||
|
||||
interface QueueChangedEvent {
|
||||
items: MediaItem[];
|
||||
currentIndex: number | null;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}
|
||||
|
||||
function createQueueStore() {
|
||||
const initialState: QueueState = {
|
||||
items: [],
|
||||
currentIndex: null,
|
||||
shuffle: false,
|
||||
repeat: "off",
|
||||
hasNext: false,
|
||||
hasPrevious: false,
|
||||
};
|
||||
|
||||
const { subscribe, set } = writable<QueueState>(initialState);
|
||||
|
||||
// Listen for queue changed events from Rust backend
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
async function init() {
|
||||
// Initial sync from backend
|
||||
await syncFromRust();
|
||||
|
||||
// Listen for queue changed events
|
||||
unlisten = await listen<QueueChangedEvent>("player-event", (event) => {
|
||||
if ((event.payload as any).type === "queue_changed") {
|
||||
const queueEvent = event.payload as any;
|
||||
set({
|
||||
items: queueEvent.items,
|
||||
currentIndex: queueEvent.current_index,
|
||||
shuffle: queueEvent.shuffle,
|
||||
repeat: queueEvent.repeat,
|
||||
hasNext: queueEvent.has_next,
|
||||
hasPrevious: queueEvent.has_previous,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync queue state from Rust backend (for initial load)
|
||||
*/
|
||||
async function syncFromRust(): Promise<void> {
|
||||
try {
|
||||
const rustQueue = await invoke<QueueChangedEvent>("player_get_queue");
|
||||
console.log("[Queue] Synced from Rust - items:", rustQueue.items.length);
|
||||
set({
|
||||
items: rustQueue.items,
|
||||
currentIndex: rustQueue.currentIndex,
|
||||
shuffle: rustQueue.shuffle,
|
||||
repeat: rustQueue.repeat,
|
||||
hasNext: rustQueue.hasNext,
|
||||
hasPrevious: rustQueue.hasPrevious,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Queue] Failed to sync from Rust:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up event listener
|
||||
*/
|
||||
function cleanup() {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on creation
|
||||
init();
|
||||
|
||||
// All queue operations now invoke backend commands
|
||||
// Backend handles all business logic and emits events
|
||||
|
||||
async function next() {
|
||||
await invoke("player_next");
|
||||
}
|
||||
|
||||
async function previous() {
|
||||
await invoke("player_previous");
|
||||
}
|
||||
|
||||
async function skipTo(index: number) {
|
||||
await invoke("player_skip_to", { index });
|
||||
}
|
||||
|
||||
async function toggleShuffle() {
|
||||
await invoke("player_toggle_shuffle");
|
||||
}
|
||||
|
||||
async function cycleRepeat() {
|
||||
await invoke("player_cycle_repeat");
|
||||
}
|
||||
|
||||
async function removeFromQueue(index: number) {
|
||||
await invoke("player_remove_from_queue", { index });
|
||||
}
|
||||
|
||||
async function moveInQueue(fromIndex: number, toIndex: number) {
|
||||
await invoke("player_move_in_queue", { fromIndex, toIndex });
|
||||
}
|
||||
|
||||
async function addToQueue(items: MediaItem | MediaItem[], position: "next" | "end" = "end") {
|
||||
const toAdd = Array.isArray(items) ? items : [items];
|
||||
const trackIds = toAdd.map((item) => item.id);
|
||||
|
||||
// Get repository handle from auth store
|
||||
const authState = get(auth);
|
||||
if (!authState.isAuthenticated || !authState.repository) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
const repositoryHandle = authState.repository.getHandle();
|
||||
|
||||
// Use new Rust commands that accept IDs only
|
||||
if (trackIds.length === 1) {
|
||||
await invoke("player_add_track_by_id", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackId: trackIds[0],
|
||||
position,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await invoke("player_add_tracks_by_ids", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds,
|
||||
position,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
next,
|
||||
previous,
|
||||
skipTo,
|
||||
toggleShuffle,
|
||||
cycleRepeat,
|
||||
addToQueue,
|
||||
removeFromQueue,
|
||||
moveInQueue,
|
||||
syncFromRust,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
export const queue = createQueueStore();
|
||||
|
||||
// Derived stores for convenience
|
||||
export const queueItems = derived(queue, ($q) => $q.items);
|
||||
export const currentQueueIndex = derived(queue, ($q) => $q.currentIndex);
|
||||
export const currentQueueItem = derived(queue, ($q) =>
|
||||
$q.currentIndex !== null ? $q.items[$q.currentIndex] : null
|
||||
);
|
||||
export const isShuffle = derived(queue, ($q) => $q.shuffle);
|
||||
export const repeatMode = derived(queue, ($q) => $q.repeat);
|
||||
export const hasNext = derived(queue, ($q) => $q.hasNext);
|
||||
export const hasPrevious = derived(queue, ($q) => $q.hasPrevious);
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
// Mock the auth store
|
||||
vi.mock("./auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(() => ({
|
||||
sessions: {
|
||||
getSessions: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("sessions store", () => {
|
||||
// Mock session data
|
||||
const mockSession1: Session = {
|
||||
id: "session-1",
|
||||
userId: "user-1",
|
||||
userName: "Test User",
|
||||
client: "Jellyfin Web",
|
||||
deviceName: "Chrome Browser",
|
||||
deviceId: "device-1",
|
||||
applicationVersion: "10.8.0",
|
||||
isActive: true,
|
||||
supportsMediaControl: true,
|
||||
supportsRemoteControl: true,
|
||||
playState: {
|
||||
positionTicks: 1000000000,
|
||||
canSeek: true,
|
||||
isPaused: false,
|
||||
isMuted: false,
|
||||
volumeLevel: 75,
|
||||
repeatMode: "RepeatNone",
|
||||
shuffleMode: "Sorted",
|
||||
},
|
||||
nowPlayingItem: {
|
||||
id: "item-1",
|
||||
name: "Test Song",
|
||||
type: "Audio",
|
||||
serverId: "server-1",
|
||||
},
|
||||
playableMediaTypes: ["Audio", "Video"],
|
||||
supportedCommands: ["PlayPause", "Stop", "Seek", "NextTrack", "PreviousTrack"],
|
||||
};
|
||||
|
||||
const mockSession2: Session = {
|
||||
id: "session-2",
|
||||
userId: "user-1",
|
||||
userName: "Test User",
|
||||
client: "Jellyfin Mobile",
|
||||
deviceName: "iPhone",
|
||||
deviceId: "device-2",
|
||||
applicationVersion: "1.0.0",
|
||||
isActive: true,
|
||||
supportsMediaControl: false,
|
||||
supportsRemoteControl: false,
|
||||
playState: null,
|
||||
nowPlayingItem: null,
|
||||
playableMediaTypes: [],
|
||||
supportedCommands: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Ensure window.setInterval and window.clearInterval are available
|
||||
if (typeof window !== 'undefined') {
|
||||
global.window = window as any;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should have empty sessions initially", async () => {
|
||||
// Import dynamically to get fresh store instance
|
||||
const { sessions } = await import("./sessions");
|
||||
const state = get(sessions);
|
||||
|
||||
expect(state.sessions).toEqual([]);
|
||||
expect(state.selectedSessionId).toBeNull();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.lastUpdated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectSession", () => {
|
||||
it("should select a session by ID", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
sessions.selectSession("session-123");
|
||||
const state = get(sessions);
|
||||
|
||||
expect(state.selectedSessionId).toBe("session-123");
|
||||
});
|
||||
|
||||
it("should allow deselecting by passing null", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
sessions.selectSession("session-123");
|
||||
sessions.selectSession(null);
|
||||
const state = get(sessions);
|
||||
|
||||
expect(state.selectedSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived stores", () => {
|
||||
it("activeSessions should filter sessions with nowPlayingItem", async () => {
|
||||
// This test would require mocking the store's internal state
|
||||
// For a real implementation, you'd need to:
|
||||
// 1. Mock the auth.getRepository().sessions.getSessions() call
|
||||
// 2. Call sessions.refresh()
|
||||
// 3. Then check the derived store
|
||||
|
||||
// Placeholder test structure:
|
||||
const { activeSessions } = await import("./sessions");
|
||||
const active = get(activeSessions);
|
||||
|
||||
// Initially empty
|
||||
expect(active).toEqual([]);
|
||||
});
|
||||
|
||||
it("selectedSession should return the selected session or null", async () => {
|
||||
const { selectedSession } = await import("./sessions");
|
||||
const selected = get(selectedSession);
|
||||
|
||||
// Initially null
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it("controllableSessions should filter sessions with supportsRemoteControl", async () => {
|
||||
const { controllableSessions } = await import("./sessions");
|
||||
const controllable = get(controllableSessions);
|
||||
|
||||
// Initially empty
|
||||
expect(controllable).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("polling", () => {
|
||||
it.skip("should set isPolling to true when polling starts", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Mock the refresh to prevent actual API calls
|
||||
vi.spyOn(sessions, "refresh").mockResolvedValue();
|
||||
|
||||
// Note: startPolling is not yet implemented
|
||||
// sessions.startPolling(5000);
|
||||
// const state = get(sessions);
|
||||
|
||||
// expect(state.isPolling).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should set isPolling to false when polling stops", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
vi.spyOn(sessions, "refresh").mockResolvedValue();
|
||||
|
||||
// Note: startPolling/stopPolling are not yet implemented
|
||||
// sessions.startPolling(5000);
|
||||
// sessions.stopPolling();
|
||||
// const state = get(sessions);
|
||||
|
||||
// expect(state.isPolling).toBe(false);
|
||||
});
|
||||
|
||||
// Note: Cannot spy on internal refresh() function as it's not exported
|
||||
it.skip("should call refresh immediately when polling starts", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
const refreshSpy = vi.spyOn(sessions, "refresh").mockResolvedValue();
|
||||
|
||||
sessions.startPolling(5000);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Note: Cannot spy on internal refresh() function as it's not exported
|
||||
it.skip("should call refresh at intervals", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
const refreshSpy = vi.spyOn(sessions, "refresh").mockResolvedValue();
|
||||
|
||||
sessions.startPolling(5000);
|
||||
|
||||
// Initial call
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance timers by 5 seconds
|
||||
await vi.advanceTimersByTime(5000);
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance another 5 seconds
|
||||
await vi.advanceTimersByTime(5000);
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
sessions.stopPolling();
|
||||
});
|
||||
|
||||
// Note: Cannot spy on internal refresh() function as it's not exported
|
||||
it.skip("should stop previous polling when starting new polling", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
const refreshSpy = vi.spyOn(sessions, "refresh").mockResolvedValue();
|
||||
|
||||
sessions.startPolling(5000);
|
||||
await vi.advanceTimersByTime(5000);
|
||||
|
||||
const callsAfterFirst = refreshSpy.mock.calls.length;
|
||||
|
||||
// Start new polling - should stop the old one
|
||||
sessions.startPolling(3000);
|
||||
|
||||
// Advance by the old interval
|
||||
await vi.advanceTimersByTime(5000);
|
||||
|
||||
// Should have been called once for the new startPolling, and once after 3s
|
||||
expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst);
|
||||
|
||||
sessions.stopPolling();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command methods", () => {
|
||||
it("sendPlayPause should call API and refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// These would need proper mocking of the auth.getRepository() chain
|
||||
// For now, we're documenting the expected behavior
|
||||
|
||||
// Mock implementation would be:
|
||||
// vi.spyOn(auth, 'getRepository').mockReturnValue({
|
||||
// sessions: {
|
||||
// sendCommand: vi.fn().mockResolvedValue(undefined)
|
||||
// }
|
||||
// });
|
||||
|
||||
// await sessions.sendPlayPause('session-123');
|
||||
// expect(mockSendCommand).toHaveBeenCalledWith('session-123', 'PlayPause');
|
||||
});
|
||||
|
||||
it("sendStop should call API and refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Similar structure to sendPlayPause test
|
||||
// Would verify sendCommand is called with 'Stop'
|
||||
});
|
||||
|
||||
it("sendNext should call API and refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Would verify sendNextTrack is called
|
||||
});
|
||||
|
||||
it("sendPrevious should call API and refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Would verify sendPreviousTrack is called
|
||||
});
|
||||
|
||||
it("sendSeek should call API without immediate refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Would verify seek is called but refresh is NOT called
|
||||
// (to avoid UI lag during seeking)
|
||||
});
|
||||
|
||||
it("sendVolume should call API without immediate refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Would verify setVolume is called but refresh is NOT called
|
||||
// (to avoid UI lag during volume changes)
|
||||
});
|
||||
|
||||
it("sendToggleMute should call API and refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Would verify toggleMute is called and refresh is called
|
||||
});
|
||||
|
||||
it("playOnSession should call API and refresh", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Would verify playOnSession is called with correct parameters
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should set error state when refresh fails", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
// Mock auth.getRepository() to throw an error
|
||||
// const error = new Error("Network error");
|
||||
// Mock implementation would set up the error
|
||||
|
||||
// await sessions.refresh();
|
||||
// const state = get(sessions);
|
||||
// expect(state.error).toBe("Network error");
|
||||
// expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("should log errors to console when commands fail", async () => {
|
||||
const { sessions } = await import("./sessions");
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Mock a failing command
|
||||
// await expect(sessions.sendPlayPause('session-1')).rejects.toThrow();
|
||||
// expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
// Remote sessions store for controlling playback on other Jellyfin clients
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
interface SessionsState {
|
||||
sessions: Session[];
|
||||
selectedSessionId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
interface PlayerStatusEvent {
|
||||
type: string;
|
||||
sessions?: Session[];
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const initialState: SessionsState = {
|
||||
sessions: [],
|
||||
selectedSessionId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
};
|
||||
|
||||
const { subscribe, update } = writable<SessionsState>(initialState);
|
||||
|
||||
// Listen for session updates from Rust backend
|
||||
listen<PlayerStatusEvent>("player-event", (event) => {
|
||||
if (event.payload.type === "sessions_updated" && event.payload.sessions) {
|
||||
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`);
|
||||
event.payload.sessions.forEach((s, i) => {
|
||||
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||
});
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessions: event.payload.sessions!,
|
||||
lastUpdated: new Date(),
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Manually fetch sessions from backend (for refresh button)
|
||||
*/
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
const sessions = await invoke<Session[]>("sessions_poll_now");
|
||||
|
||||
console.log(`[Sessions] Manual refresh returned ${sessions.length} sessions`);
|
||||
sessions.forEach((s, i) => {
|
||||
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||
});
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessions,
|
||||
isLoading: false,
|
||||
lastUpdated: new Date(),
|
||||
error: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch sessions";
|
||||
update((s) => ({
|
||||
...s,
|
||||
isLoading: false,
|
||||
error: message,
|
||||
}));
|
||||
console.error("Failed to fetch sessions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Select a session for control
|
||||
*/
|
||||
function selectSession(sessionId: string | null): void {
|
||||
update((s) => ({ ...s, selectedSessionId: sessionId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send play/pause toggle command
|
||||
*/
|
||||
async function sendPlayPause(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "PlayPause",
|
||||
});
|
||||
// Refresh after command to get updated state
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send play/pause command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop command
|
||||
*/
|
||||
async function sendStop(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "Stop",
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send next track command
|
||||
*/
|
||||
async function sendNext(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "NextTrack",
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send next track command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send previous track command
|
||||
*/
|
||||
async function sendPrevious(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "PreviousTrack",
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send previous track command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position (in ticks)
|
||||
*/
|
||||
async function sendSeek(sessionId: string, positionTicks: number): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_session_seek", {
|
||||
sessionId,
|
||||
positionTicks,
|
||||
});
|
||||
// Don't refresh immediately for seek to avoid UI lag
|
||||
} catch (error) {
|
||||
console.error("Failed to send seek command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume (0-100)
|
||||
*/
|
||||
async function sendVolume(sessionId: string, volume: number): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_session_set_volume", {
|
||||
sessionId,
|
||||
volume,
|
||||
});
|
||||
// Don't refresh immediately for volume to avoid UI lag
|
||||
} catch (error) {
|
||||
console.error("Failed to send volume command:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
async function sendToggleMute(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "ToggleMute",
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle mute:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play item(s) on remote session
|
||||
*/
|
||||
async function playOnSession(
|
||||
sessionId: string,
|
||||
itemIds: string[],
|
||||
startIndex = 0
|
||||
): Promise<void> {
|
||||
console.log("[SESSIONS] ========== playOnSession called ==========");
|
||||
console.log("[SESSIONS] sessionId:", sessionId);
|
||||
console.log("[SESSIONS] itemIds array:", itemIds);
|
||||
console.log("[SESSIONS] itemIds.length:", itemIds.length);
|
||||
console.log("[SESSIONS] itemIds JSON:", JSON.stringify(itemIds));
|
||||
console.log("[SESSIONS] startIndex:", startIndex);
|
||||
console.log("[SESSIONS] About to call invoke('remote_play_on_session')");
|
||||
try {
|
||||
// Use Rust player's Jellyfin client for remote playback
|
||||
const result = await invoke("remote_play_on_session", {
|
||||
sessionId,
|
||||
itemIds,
|
||||
startIndex,
|
||||
});
|
||||
console.log("[SESSIONS] invoke succeeded, result:", result);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("[SESSIONS] Failed to play on session:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh,
|
||||
selectSession,
|
||||
sendPlayPause,
|
||||
sendStop,
|
||||
sendNext,
|
||||
sendPrevious,
|
||||
sendSeek,
|
||||
sendVolume,
|
||||
sendToggleMute,
|
||||
playOnSession,
|
||||
};
|
||||
}
|
||||
|
||||
export const sessions = createSessionsStore();
|
||||
|
||||
// Derived stores
|
||||
|
||||
/**
|
||||
* Sessions that are currently playing media
|
||||
*/
|
||||
export const activeSessions = derived(
|
||||
sessions,
|
||||
($sessions) => $sessions.sessions.filter((s) => s.nowPlayingItem !== null)
|
||||
);
|
||||
|
||||
/**
|
||||
* Currently selected session
|
||||
*/
|
||||
export const selectedSession = derived(
|
||||
sessions,
|
||||
($sessions) =>
|
||||
$sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null
|
||||
);
|
||||
|
||||
/**
|
||||
* Controllable sessions (support remote control)
|
||||
*/
|
||||
export const controllableSessions = derived(
|
||||
sessions,
|
||||
($sessions) => {
|
||||
const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl);
|
||||
console.log(`[Sessions] Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`);
|
||||
$sessions.sessions.forEach((s, i) => {
|
||||
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
|
||||
console.log(`[Sessions] ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`);
|
||||
});
|
||||
return controllable;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Sleep Timer Store (Display Only - Backend-First Architecture)
|
||||
*
|
||||
* This store reflects sleep timer state from the backend.
|
||||
* All logic is in the Rust backend (PlayerController).
|
||||
*
|
||||
* The backend emits SleepTimerChanged events to update this store.
|
||||
*/
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
|
||||
export type SleepTimerMode =
|
||||
| { kind: "off" }
|
||||
| { kind: "time"; endTime: number }
|
||||
| { kind: "endOfTrack" }
|
||||
| { kind: "episodes"; remaining: number };
|
||||
|
||||
interface SleepTimerState {
|
||||
mode: SleepTimerMode;
|
||||
remainingSeconds: number;
|
||||
}
|
||||
|
||||
function createSleepTimerStore() {
|
||||
const initialState: SleepTimerState = {
|
||||
mode: { kind: "off" },
|
||||
remainingSeconds: 0,
|
||||
};
|
||||
|
||||
const { subscribe, set } = writable<SleepTimerState>(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set, // Updated by playerEvents.ts when backend emits SleepTimerChanged event
|
||||
};
|
||||
}
|
||||
|
||||
export const sleepTimer = createSleepTimerStore();
|
||||
|
||||
// Derived stores for convenient access
|
||||
export const sleepTimerMode = derived(sleepTimer, ($s) => $s.mode);
|
||||
|
||||
export const sleepTimerActive = derived(
|
||||
sleepTimer,
|
||||
($s) => $s.mode.kind !== "off"
|
||||
);
|
||||
|
||||
export const sleepTimerRemainingSeconds = derived(
|
||||
sleepTimer,
|
||||
($s) => $s.remainingSeconds
|
||||
);
|
||||
|
||||
export const sleepTimerRemainingEpisodes = derived(sleepTimer, ($s) =>
|
||||
$s.mode.kind === "episodes" ? $s.mode.remaining : 0
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<ToastStore>({ toasts: [] });
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
show: (message: string, type: Toast["type"] = "info", duration = 3000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random()}`;
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
|
||||
update((store) => ({
|
||||
toasts: [...store.toasts, toast],
|
||||
}));
|
||||
|
||||
// Auto-dismiss after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
update((store) => ({
|
||||
toasts: store.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
dismiss: (id: string) => {
|
||||
update((store) => ({
|
||||
toasts: store.toasts.filter((t) => t.id !== id),
|
||||
}));
|
||||
},
|
||||
success: (message: string, duration?: number) => {
|
||||
return createToastStore().show(message, "success", duration);
|
||||
},
|
||||
error: (message: string, duration?: number) => {
|
||||
return createToastStore().show(message, "error", duration);
|
||||
},
|
||||
info: (message: string, duration?: number) => {
|
||||
return createToastStore().show(message, "info", duration);
|
||||
},
|
||||
warning: (message: string, duration?: number) => {
|
||||
return createToastStore().show(message, "warning", duration);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
Reference in New Issue
Block a user