diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index 903e427..32c2a72 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -60,9 +60,11 @@ pub struct UserData { /// Artist item with ID and name (for clickable artist links) #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "PascalCase")] +#[serde(rename_all = "camelCase")] pub struct ArtistItem { + #[serde(alias = "Id")] pub id: String, + #[serde(alias = "Name")] pub name: String, } @@ -118,6 +120,7 @@ pub struct MediaItem { #[serde(skip_serializing_if = "Option::is_none")] pub official_rating: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "runTimeTicks")] pub runtime_ticks: Option, #[serde(skip_serializing_if = "Option::is_none")] pub primary_image_tag: Option, diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 809eba0..4d9d39f 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -1271,7 +1271,7 @@ export type AlbumStorageInfo = { album_id: string; album_name: string; artist_na /** * Artist item with ID and name (for clickable artist links) */ -export type ArtistItem = { Id: string; Name: string } +export type ArtistItem = { id: string; name: string } /** * Audio playback settings */ @@ -1452,7 +1452,7 @@ export type Library = { id: string; name: string; collectionType: string; imageT /** * Media item */ -export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runtimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } +export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } /** * Media session type tracking the high-level playback context */ @@ -1764,7 +1764,7 @@ export type PlaylistEntry = /** * The underlying media item */ -({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runtimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { +({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { /** * The playlist-scoped entry ID (Jellyfin's PlaylistItemId) */ diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 1c3afaa..e496f7a 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -1,76 +1,41 @@ -// Jellyfin API Types +// Frontend type layer. +// +// Wire types are the single source of truth, generated from Rust by tauri-specta +// in ./bindings.ts. This module re-exports them under the names the frontend uses +// and adds frontend-only unions/helpers that have no backend equivalent. -export interface ServerInfo { - id: string; - name: string; - url: string; -} +export type { + AuthResult, + Genre, + GetItemsOptions, + ImageOptions, + ImageType, + Library, + MediaItem, + MediaSource, + MediaStream, + Person, + PlaybackInfo, + PlaylistCreatedResult, + PlaylistEntry, + PlayState, + SearchOptions, + SearchResult, + ServerInfo, + User, + UserData, +} from "./bindings"; -export interface User { - id: string; - name: string; - serverId: string; - primaryImageTag?: string; -} +import type { SessionInfo } from "./bindings"; -export interface AuthResult { - user: User; - accessToken: string; - serverId: string; -} +// The frontend's "Session" is the backend remote-control session (SessionInfo). +// (Note: bindings also has a `Session` type — that's the auth/login session, a +// different concept — so we alias explicitly here rather than re-export it.) +export type Session = SessionInfo; -export interface Library { - id: string; - name: string; - collectionType: LibraryType; - imageTag?: string; -} - -export type LibraryType = "movies" | "tvshows" | "music" | "books" | "photos" | "homevideos" | "boxsets" | "playlists" | "channels" | "unknown"; - -export interface MediaItem { - id: string; - name: string; - type: ItemType; - serverId: string; - - // Common fields - overview?: string; - productionYear?: number; - communityRating?: number; - officialRating?: string; - runTimeTicks?: number; - - // Images - primaryImageTag?: string; - backdropImageTags?: string[]; - parentBackdropImageTags?: string[]; - - // For audio - albumId?: string; - albumName?: string; - artists?: string[]; - artistItems?: { id: string; name: string }[]; - indexNumber?: number; // Track number - parentIndexNumber?: number; // Disc number - - // For video - seriesId?: string; - seriesName?: string; - seasonId?: string; - seasonName?: string; - - // Playback - userData?: UserData; - mediaStreams?: MediaStream[]; - mediaSources?: MediaSource[]; - - // Cast & Crew - people?: Person[]; - - // Genres - genres?: string[]; -} +// --------------------------------------------------------------------------- +// Frontend-only types (not part of the Rust command surface) +// --------------------------------------------------------------------------- export type ItemType = | "Movie" @@ -87,7 +52,18 @@ export type ItemType = | "ChannelFolderItem" | "Person"; -// Person/Cast types +export type LibraryType = + | "movies" + | "tvshows" + | "music" + | "books" + | "photos" + | "homevideos" + | "boxsets" + | "playlists" + | "channels" + | "unknown"; + export type PersonType = | "Actor" | "Director" @@ -99,81 +75,6 @@ export type PersonType = | "Conductor" | "Lyricist"; -export interface Person { - id: string; - name: string; - role?: string; // Character name for actors - type: PersonType; - primaryImageTag?: string; -} - -export interface UserData { - playbackPositionTicks: number; - playCount: number; - isFavorite: boolean; - played: boolean; - lastPlayedDate?: string; -} - -export interface MediaStream { - type: "Video" | "Audio" | "Subtitle"; - codec?: string; - language?: string; - displayTitle?: string; - index: number; - isDefault: boolean; - isForced: boolean; -} - -export interface MediaSource { - id: string; - name: string; - container?: string; - size?: number; - bitrate?: number; - supportsDirectPlay: boolean; - supportsDirectStream: boolean; - supportsTranscoding: boolean; - directStreamUrl?: string; -} - -export interface PlaybackInfo { - mediaSourceId: string; - playSessionId: string; - streamUrl: string; - directPlay: boolean; - /** True if content requires transcoding (HEVC, 10-bit) - seeking requires server-side StartTimeTicks */ - needsTranscoding: boolean; -} - -// Remote session control types -export interface Session { - id: string; - userId: string; - userName: string; - client: string; - deviceName: string; - deviceId: string; - applicationVersion: string; - isActive: boolean; - supportsMediaControl: boolean; - supportsRemoteControl: boolean; - playState: PlayState | null; - nowPlayingItem: MediaItem | null; - playableMediaTypes: string[]; - supportedCommands: string[]; -} - -export interface PlayState { - positionTicks: number; - canSeek: boolean; - isPaused: boolean; - isMuted: boolean; - volumeLevel: number; - repeatMode: string; - shuffleMode: string; -} - export type SessionCommand = | "PlayPause" | "Stop" @@ -183,94 +84,3 @@ export type SessionCommand = | "PreviousTrack" | "Mute" | "Unmute"; - -export interface SearchResult { - items: MediaItem[]; - totalRecordCount: number; -} - -// Repository pattern for offline support -export interface MediaRepository { - // Libraries - getLibraries(): Promise; - - // Items - getItems(parentId: string, options?: GetItemsOptions): Promise; - getItem(itemId: string): Promise; - getLatestItems(parentId: string, limit?: number): Promise; - - // Home screen - getResumeItems(parentId?: string, limit?: number): Promise; - getNextUpEpisodes(seriesId?: string, limit?: number): Promise; - getRecentlyPlayedAudio(limit?: number): Promise; - getResumeMovies(limit?: number): Promise; - - // Genres - getGenres(parentId?: string): Promise; - - // Search - search(query: string, options?: SearchOptions): Promise; - - // Playback - getPlaybackInfo(itemId: string): Promise; - reportPlaybackStart(itemId: string, positionTicks: number): Promise; - reportPlaybackProgress(itemId: string, positionTicks: number): Promise; - reportPlaybackStopped(itemId: string, positionTicks: number): Promise; - - // Images - getImageUrl(itemId: string, imageType: ImageType, options?: ImageOptions): string; - - // Subtitles - getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format?: string): string; - - // Favorites - markFavorite(itemId: string): Promise; - unmarkFavorite(itemId: string): Promise; - - // People/Cast - getPerson(personId: string): Promise; - getItemsByPerson(personId: string, options?: GetItemsOptions): Promise; - - // Related Content - getSimilarItems(itemId: string, limit?: number): Promise; -} - -export interface Genre { - id: string; - name: string; -} - -// Playlist types -export interface PlaylistEntry extends MediaItem { - playlistItemId: string; -} - -export interface PlaylistCreatedResult { - id: string; -} - -export interface GetItemsOptions { - startIndex?: number; - limit?: number; - sortBy?: string; - sortOrder?: "Ascending" | "Descending"; - includeItemTypes?: ItemType[]; - recursive?: boolean; - fields?: string[]; - genres?: string[]; -} - -export interface SearchOptions { - limit?: number; - includeItemTypes?: ItemType[]; - searchTerm?: string; -} - -export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"; - -export interface ImageOptions { - maxWidth?: number; - maxHeight?: number; - quality?: number; - tag?: string; -} diff --git a/src/lib/stores/player.ts b/src/lib/stores/player.ts index e7252be..4bd21a7 100644 --- a/src/lib/stores/player.ts +++ b/src/lib/stores/player.ts @@ -9,7 +9,8 @@ */ import { writable, derived } from "svelte/store"; -import type { MediaItem } from "$lib/api/types"; +import type { MediaItem, ItemType } from "$lib/api/types"; +import type { NowPlayingItem } from "$lib/api/bindings"; import { isRemoteMode } from "./playbackMode"; import { selectedSession } from "./sessions"; import { ticksToSeconds } from "$lib/utils/playbackUnits"; @@ -161,13 +162,36 @@ export const isMuted = derived(player, ($p) => $p.muted); /** * Merged media item - prefers remote session when in remote mode */ -export const mergedMedia = derived( +/** + * Normalize a remote session's NowPlayingItem into the MediaItem shape the UI + * renders, so display components can treat local and remote items uniformly. + * (NowPlayingItem has `album`/`artists` but no `albumName`/`artistItems`; the UI + * falls back to the `artists` string list when `artistItems` is absent.) + */ +function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { + return { + id: npi.id ?? "", + name: npi.name ?? "", + type: (npi.Type ?? "Audio") as ItemType, + serverId: "", + albumName: npi.album, + albumId: npi.albumId, + artists: npi.artists, + primaryImageTag: npi.primaryImageTag ?? npi.albumPrimaryImageTag, + runTimeTicks: npi.runTimeTicks, + } as MediaItem; +} + +export const mergedMedia = derived< + [typeof isRemoteMode, typeof selectedSession, typeof currentMedia], + MediaItem | null +>( [isRemoteMode, selectedSession, currentMedia], ([$isRemote, $session, $local]) => { if ($isRemote && $session?.nowPlayingItem) { - return $session.nowPlayingItem; + return nowPlayingToMediaItem($session.nowPlayingItem); } - return $local; + return $local ?? null; } );