Compare commits
No commits in common. "14e9d7e03a010e3dfd2533ccf06527bdba9723e4" and "6146d70bc53a83a7bf08b621020f0ab4c90cb90b" have entirely different histories.
14e9d7e03a
...
6146d70bc5
@ -60,11 +60,9 @@ pub struct UserData {
|
|||||||
|
|
||||||
/// Artist item with ID and name (for clickable artist links)
|
/// Artist item with ID and name (for clickable artist links)
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct ArtistItem {
|
pub struct ArtistItem {
|
||||||
#[serde(alias = "Id")]
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(alias = "Name")]
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +118,6 @@ pub struct MediaItem {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub official_rating: Option<String>,
|
pub official_rating: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "runTimeTicks")]
|
|
||||||
pub runtime_ticks: Option<i64>,
|
pub runtime_ticks: Option<i64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub primary_image_tag: Option<String>,
|
pub primary_image_tag: Option<String>,
|
||||||
@ -405,21 +402,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_artist_item_serialize() {
|
fn test_artist_item_serialize() {
|
||||||
// ArtistItem serializes to camelCase for the frontend; it still accepts
|
// Test that ArtistItem serializes to PascalCase for consistency
|
||||||
// Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
|
|
||||||
let artist = ArtistItem {
|
let artist = ArtistItem {
|
||||||
id: "test-id".to_string(),
|
id: "test-id".to_string(),
|
||||||
name: "Test Artist".to_string(),
|
name: "Test Artist".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&artist).expect("Failed to serialize");
|
let json = serde_json::to_string(&artist).expect("Failed to serialize");
|
||||||
assert!(json.contains(r#""id":"test-id""#));
|
assert!(json.contains(r#""Id":"test-id""#));
|
||||||
assert!(json.contains(r#""name":"Test Artist""#));
|
assert!(json.contains(r#""Name":"Test Artist""#));
|
||||||
|
|
||||||
let from_pascal: ArtistItem =
|
|
||||||
serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
|
|
||||||
assert_eq!(from_pascal.id, "x");
|
|
||||||
assert_eq!(from_pascal.name, "Y");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -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)
|
* 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
|
* Audio playback settings
|
||||||
*/
|
*/
|
||||||
@ -1452,7 +1452,7 @@ export type Library = { id: string; name: string; collectionType: string; imageT
|
|||||||
/**
|
/**
|
||||||
* Media item
|
* 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
|
* Media session type tracking the high-level playback context
|
||||||
*/
|
*/
|
||||||
@ -1764,7 +1764,7 @@ export type PlaylistEntry =
|
|||||||
/**
|
/**
|
||||||
* The underlying media item
|
* 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)
|
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,41 +1,76 @@
|
|||||||
// Frontend type layer.
|
// Jellyfin API Types
|
||||||
//
|
|
||||||
// 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 type {
|
export interface ServerInfo {
|
||||||
AuthResult,
|
id: string;
|
||||||
Genre,
|
name: string;
|
||||||
GetItemsOptions,
|
url: string;
|
||||||
ImageOptions,
|
}
|
||||||
ImageType,
|
|
||||||
Library,
|
|
||||||
MediaItem,
|
|
||||||
MediaSource,
|
|
||||||
MediaStream,
|
|
||||||
Person,
|
|
||||||
PlaybackInfo,
|
|
||||||
PlaylistCreatedResult,
|
|
||||||
PlaylistEntry,
|
|
||||||
PlayState,
|
|
||||||
SearchOptions,
|
|
||||||
SearchResult,
|
|
||||||
ServerInfo,
|
|
||||||
User,
|
|
||||||
UserData,
|
|
||||||
} from "./bindings";
|
|
||||||
|
|
||||||
import type { SessionInfo } from "./bindings";
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serverId: string;
|
||||||
|
primaryImageTag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// The frontend's "Session" is the backend remote-control session (SessionInfo).
|
export interface AuthResult {
|
||||||
// (Note: bindings also has a `Session` type — that's the auth/login session, a
|
user: User;
|
||||||
// different concept — so we alias explicitly here rather than re-export it.)
|
accessToken: string;
|
||||||
export type Session = SessionInfo;
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export interface Library {
|
||||||
// Frontend-only types (not part of the Rust command surface)
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
export type ItemType =
|
export type ItemType =
|
||||||
| "Movie"
|
| "Movie"
|
||||||
@ -52,18 +87,7 @@ export type ItemType =
|
|||||||
| "ChannelFolderItem"
|
| "ChannelFolderItem"
|
||||||
| "Person";
|
| "Person";
|
||||||
|
|
||||||
export type LibraryType =
|
// Person/Cast types
|
||||||
| "movies"
|
|
||||||
| "tvshows"
|
|
||||||
| "music"
|
|
||||||
| "books"
|
|
||||||
| "photos"
|
|
||||||
| "homevideos"
|
|
||||||
| "boxsets"
|
|
||||||
| "playlists"
|
|
||||||
| "channels"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export type PersonType =
|
export type PersonType =
|
||||||
| "Actor"
|
| "Actor"
|
||||||
| "Director"
|
| "Director"
|
||||||
@ -75,6 +99,81 @@ export type PersonType =
|
|||||||
| "Conductor"
|
| "Conductor"
|
||||||
| "Lyricist";
|
| "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 =
|
export type SessionCommand =
|
||||||
| "PlayPause"
|
| "PlayPause"
|
||||||
| "Stop"
|
| "Stop"
|
||||||
@ -84,3 +183,94 @@ export type SessionCommand =
|
|||||||
| "PreviousTrack"
|
| "PreviousTrack"
|
||||||
| "Mute"
|
| "Mute"
|
||||||
| "Unmute";
|
| "Unmute";
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
items: MediaItem[];
|
||||||
|
totalRecordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository pattern for offline support
|
||||||
|
export interface MediaRepository {
|
||||||
|
// Libraries
|
||||||
|
getLibraries(): Promise<Library[]>;
|
||||||
|
|
||||||
|
// Items
|
||||||
|
getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult>;
|
||||||
|
getItem(itemId: string): Promise<MediaItem>;
|
||||||
|
getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]>;
|
||||||
|
|
||||||
|
// Home screen
|
||||||
|
getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]>;
|
||||||
|
getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]>;
|
||||||
|
getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]>;
|
||||||
|
getResumeMovies(limit?: number): Promise<MediaItem[]>;
|
||||||
|
|
||||||
|
// Genres
|
||||||
|
getGenres(parentId?: string): Promise<Genre[]>;
|
||||||
|
|
||||||
|
// Search
|
||||||
|
search(query: string, options?: SearchOptions): Promise<SearchResult>;
|
||||||
|
|
||||||
|
// Playback
|
||||||
|
getPlaybackInfo(itemId: string): Promise<PlaybackInfo>;
|
||||||
|
reportPlaybackStart(itemId: string, positionTicks: number): Promise<void>;
|
||||||
|
reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void>;
|
||||||
|
reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void>;
|
||||||
|
|
||||||
|
// Images
|
||||||
|
getImageUrl(itemId: string, imageType: ImageType, options?: ImageOptions): string;
|
||||||
|
|
||||||
|
// Subtitles
|
||||||
|
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format?: string): string;
|
||||||
|
|
||||||
|
// Favorites
|
||||||
|
markFavorite(itemId: string): Promise<void>;
|
||||||
|
unmarkFavorite(itemId: string): Promise<void>;
|
||||||
|
|
||||||
|
// People/Cast
|
||||||
|
getPerson(personId: string): Promise<MediaItem>;
|
||||||
|
getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult>;
|
||||||
|
|
||||||
|
// Related Content
|
||||||
|
getSimilarItems(itemId: string, limit?: number): Promise<SearchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Genre {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
imageType?: string;
|
imageType?: string;
|
||||||
tag?: string | null;
|
tag?: string;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|||||||
@ -28,7 +28,6 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const type = person.type;
|
const type = person.type;
|
||||||
if (!type) continue;
|
|
||||||
if (type in groups) {
|
if (type in groups) {
|
||||||
groups[type].push(person);
|
groups[type].push(person);
|
||||||
} else {
|
} else {
|
||||||
@ -85,7 +84,7 @@
|
|||||||
<!-- Person image -->
|
<!-- Person image -->
|
||||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
itemId={person.id ?? ""}
|
itemId={person.id}
|
||||||
imageType="Primary"
|
imageType="Primary"
|
||||||
tag={person.primaryImageTag}
|
tag={person.primaryImageTag}
|
||||||
maxWidth={200}
|
maxWidth={200}
|
||||||
|
|||||||
@ -55,7 +55,7 @@
|
|||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#each filteredPeople as person, index (person.id)}
|
{#each filteredPeople as person, index (person.id)}
|
||||||
<button
|
<button
|
||||||
onclick={(e) => handlePersonClick(person.id ?? "", e)}
|
onclick={(e) => handlePersonClick(person.id, e)}
|
||||||
class="text-[var(--color-jellyfin)] hover:underline"
|
class="text-[var(--color-jellyfin)] hover:underline"
|
||||||
>
|
>
|
||||||
{person.name}
|
{person.name}
|
||||||
|
|||||||
@ -84,7 +84,7 @@
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDuration(ticks?: number | null): string {
|
function formatDuration(ticks?: number): string {
|
||||||
if (!ticks) return "";
|
if (!ticks) return "";
|
||||||
const seconds = Math.floor(ticks / 10000000);
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@ -100,7 +100,7 @@
|
|||||||
if (!ep.userData || !ep.runTimeTicks) {
|
if (!ep.userData || !ep.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
|
return (ep.userData.playbackPositionTicks / ep.runTimeTicks) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlay() {
|
function handlePlay() {
|
||||||
@ -178,7 +178,7 @@
|
|||||||
{episode.communityRating.toFixed(1)}
|
{episode.communityRating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if episode.userData?.isPlayed}
|
{#if episode.userData?.played}
|
||||||
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
|
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
@ -281,7 +281,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Played indicator -->
|
<!-- Played indicator -->
|
||||||
{#if ep.userData?.isPlayed}
|
{#if ep.userData?.played}
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
|||||||
@ -40,7 +40,7 @@
|
|||||||
if (!episode.userData || !episode.runTimeTicks) {
|
if (!episode.userData || !episode.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
|
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||||
@ -137,7 +137,7 @@
|
|||||||
{episode.name}
|
{episode.name}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- Played indicator -->
|
<!-- Played indicator -->
|
||||||
{#if episode.userData?.isPlayed}
|
{#if episode.userData?.played}
|
||||||
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -164,10 +164,10 @@
|
|||||||
<VideoDownloadButton
|
<VideoDownloadButton
|
||||||
itemId={episode.id}
|
itemId={episode.id}
|
||||||
itemName={episode.name}
|
itemName={episode.name}
|
||||||
seriesName={episode.seriesName ?? undefined}
|
seriesName={episode.seriesName}
|
||||||
seasonName={episode.seasonName ?? undefined}
|
seasonName={episode.seasonName}
|
||||||
episodeNumber={episode.indexNumber ?? undefined}
|
episodeNumber={episode.indexNumber}
|
||||||
seasonNumber={episode.parentIndexNumber ?? undefined}
|
seasonNumber={episode.parentIndexNumber}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||||
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSubtitle(item: MediaItem | Library): string {
|
function getSubtitle(item: MediaItem | Library): string {
|
||||||
@ -44,7 +44,7 @@
|
|||||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrackNumber(item: MediaItem | Library): string {
|
function getTrackNumber(item: MediaItem | Library): string {
|
||||||
@ -61,7 +61,7 @@
|
|||||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||||
{@const progress = getProgress(item)}
|
{@const progress = getProgress(item)}
|
||||||
{@const trackNum = getTrackNumber(item)}
|
{@const trackNum = getTrackNumber(item)}
|
||||||
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
|
{@const isPlayed = "userData" in item && item.userData?.played}
|
||||||
{@const downloadInfo = getDownloadInfo(item.id)}
|
{@const downloadInfo = getDownloadInfo(item.id)}
|
||||||
{@const isDownloaded = downloadInfo?.status === "completed"}
|
{@const isDownloaded = downloadInfo?.status === "completed"}
|
||||||
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtitle = $derived(() => {
|
const subtitle = $derived(() => {
|
||||||
@ -112,7 +112,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Played indicator -->
|
<!-- Played indicator -->
|
||||||
{#if "userData" in item && item.userData?.isPlayed}
|
{#if "userData" in item && item.userData?.played}
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
|||||||
@ -145,7 +145,7 @@
|
|||||||
goto(`/library/${artistId}`);
|
goto(`/library/${artistId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAlbumClick(albumId: string | null | undefined, e: Event) {
|
function handleAlbumClick(albumId: string | undefined, e: Event) {
|
||||||
if (!albumId) return;
|
if (!albumId) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
goto(`/library/${albumId}`);
|
goto(`/library/${albumId}`);
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
let dragDisabled = $state(true);
|
let dragDisabled = $state(true);
|
||||||
const flipDurationMs = 200;
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
function formatDuration(ticks?: number | null): string {
|
function formatDuration(ticks?: number): string {
|
||||||
if (!ticks) return "";
|
if (!ticks) return "";
|
||||||
const seconds = Math.floor(ticks / 10000000);
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
|
|||||||
@ -15,8 +15,8 @@
|
|||||||
const nowPlaying = $derived(session.nowPlayingItem);
|
const nowPlaying = $derived(session.nowPlayingItem);
|
||||||
const supportsSeek = $derived(playState?.canSeek ?? false);
|
const supportsSeek = $derived(playState?.canSeek ?? false);
|
||||||
const supportsNextPrevious = $derived(
|
const supportsNextPrevious = $derived(
|
||||||
(session.supportedCommands ?? []).includes("NextTrack") &&
|
session.supportedCommands.includes("NextTrack") &&
|
||||||
(session.supportedCommands ?? []).includes("PreviousTrack")
|
session.supportedCommands.includes("PreviousTrack")
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handlePlayPause() {
|
async function handlePlayPause() {
|
||||||
@ -107,8 +107,8 @@
|
|||||||
<h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4>
|
<h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4>
|
||||||
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
|
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
|
||||||
<p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
|
<p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
|
||||||
{:else if nowPlaying?.album}
|
{:else if nowPlaying.albumName}
|
||||||
<p class="text-sm text-gray-400 truncate">{nowPlaying?.album}</p>
|
<p class="text-sm text-gray-400 truncate">{nowPlaying.albumName}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -196,9 +196,9 @@
|
|||||||
{#if playState.volumeLevel !== undefined}
|
{#if playState.volumeLevel !== undefined}
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
{#if playState.isMuted || (playState.volumeLevel ?? 0) === 0}
|
{#if playState.isMuted || playState.volumeLevel === 0}
|
||||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||||
{:else if (playState.volumeLevel ?? 0) < 50}
|
{:else if playState.volumeLevel < 50}
|
||||||
<path d="M7 9v6h4l5 5V4l-5 5H7z" />
|
<path d="M7 9v6h4l5 5V4l-5 5H7z" />
|
||||||
{:else}
|
{:else}
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
||||||
|
|||||||
@ -51,11 +51,11 @@
|
|||||||
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
||||||
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
itemId={nowPlaying.id ?? ""}
|
itemId={nowPlaying.id}
|
||||||
imageType="Primary"
|
imageType="Primary"
|
||||||
tag={nowPlaying.primaryImageTag}
|
tag={nowPlaying.primaryImageTag}
|
||||||
maxWidth={80}
|
maxWidth={80}
|
||||||
alt={nowPlaying.name ?? undefined}
|
alt={nowPlaying.name}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -64,8 +64,8 @@
|
|||||||
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
||||||
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
|
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
|
||||||
<p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
|
<p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
|
||||||
{:else if nowPlaying.album}
|
{:else if nowPlaying.albumName}
|
||||||
<p class="text-xs text-gray-400 truncate">{nowPlaying.album}</p>
|
<p class="text-xs text-gray-400 truncate">{nowPlaying.albumName}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
|||||||
@ -63,8 +63,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionIcon(client: string | null | undefined): string {
|
function getSessionIcon(client: string): string {
|
||||||
const clientLower = (client ?? "").toLowerCase();
|
const clientLower = client.toLowerCase();
|
||||||
if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) {
|
if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) {
|
||||||
return "tv";
|
return "tv";
|
||||||
} else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) {
|
} else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) {
|
||||||
|
|||||||
@ -68,7 +68,7 @@
|
|||||||
<SessionCard
|
<SessionCard
|
||||||
{session}
|
{session}
|
||||||
selected={$sessions.selectedSessionId === session.id}
|
selected={$sessions.selectedSessionId === session.id}
|
||||||
onclick={() => handleSessionClick(session.id ?? "")}
|
onclick={() => handleSessionClick(session.id)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -80,17 +80,14 @@ describe("downloads store", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("download_item", {
|
expect(mockInvoke).toHaveBeenCalledWith("download_item", {
|
||||||
request: {
|
|
||||||
itemId: "item-1",
|
itemId: "item-1",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
filePath: "/path/to/file.mp3",
|
filePath: "/path/to/file.mp3",
|
||||||
mimeType: "audio/mpeg",
|
mimeType: "audio/mpeg",
|
||||||
priority: 10,
|
priority: 10,
|
||||||
itemName: null,
|
itemName: undefined,
|
||||||
artistName: null,
|
artistName: undefined,
|
||||||
albumName: null,
|
albumName: undefined,
|
||||||
expectedSize: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(downloadId).toBe(123);
|
expect(downloadId).toBe(123);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -73,7 +73,7 @@ function createPlaybackModeStore() {
|
|||||||
* - Polls remote session until track loads
|
* - Polls remote session until track loads
|
||||||
* - Stops local playback
|
* - Stops local playback
|
||||||
*/
|
*/
|
||||||
async function transferToRemote(sessionId: string | null | undefined): Promise<void> {
|
async function transferToRemote(sessionId: string): Promise<void> {
|
||||||
console.log("[PlaybackMode] Transferring to remote session:", sessionId);
|
console.log("[PlaybackMode] Transferring to remote session:", sessionId);
|
||||||
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
||||||
|
|
||||||
@ -100,11 +100,11 @@ function createPlaybackModeStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
sessions.selectSession(sessionId ?? null);
|
sessions.selectSession(sessionId);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
mode: "remote",
|
mode: "remote",
|
||||||
remoteSessionId: sessionId ?? null,
|
remoteSessionId: sessionId,
|
||||||
isTransferring: false,
|
isTransferring: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem, ItemType } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import type { NowPlayingItem } from "$lib/api/bindings";
|
|
||||||
import { isRemoteMode } from "./playbackMode";
|
import { isRemoteMode } from "./playbackMode";
|
||||||
import { selectedSession } from "./sessions";
|
import { selectedSession } from "./sessions";
|
||||||
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
||||||
@ -162,36 +161,13 @@ export const isMuted = derived(player, ($p) => $p.muted);
|
|||||||
/**
|
/**
|
||||||
* Merged media item - prefers remote session when in remote mode
|
* 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],
|
[isRemoteMode, selectedSession, currentMedia],
|
||||||
([$isRemote, $session, $local]) => {
|
([$isRemote, $session, $local]) => {
|
||||||
if ($isRemote && $session?.nowPlayingItem) {
|
if ($isRemote && $session?.nowPlayingItem) {
|
||||||
return nowPlayingToMediaItem($session.nowPlayingItem);
|
return $session.nowPlayingItem;
|
||||||
}
|
}
|
||||||
return $local ?? null;
|
return $local;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -40,15 +40,8 @@ describe("sessions store", () => {
|
|||||||
nowPlayingItem: {
|
nowPlayingItem: {
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
name: "Test Song",
|
name: "Test Song",
|
||||||
Type: "Audio",
|
type: "Audio",
|
||||||
runTimeTicks: null,
|
serverId: "server-1",
|
||||||
album: null,
|
|
||||||
albumId: null,
|
|
||||||
albumArtist: null,
|
|
||||||
artists: null,
|
|
||||||
imageTags: null,
|
|
||||||
primaryImageTag: null,
|
|
||||||
albumPrimaryImageTag: null,
|
|
||||||
},
|
},
|
||||||
playableMediaTypes: ["Audio", "Video"],
|
playableMediaTypes: ["Audio", "Video"],
|
||||||
supportedCommands: ["PlayPause", "Stop", "Seek", "NextTrack", "PreviousTrack"],
|
supportedCommands: ["PlayPause", "Stop", "Seek", "NextTrack", "PreviousTrack"],
|
||||||
|
|||||||
@ -89,7 +89,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Send play/pause toggle command
|
* Send play/pause toggle command
|
||||||
*/
|
*/
|
||||||
async function sendPlayPause(sessionId: string | null | undefined): Promise<void> {
|
async function sendPlayPause(sessionId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_send_command", {
|
await invoke("remote_send_command", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -106,7 +106,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Send stop command
|
* Send stop command
|
||||||
*/
|
*/
|
||||||
async function sendStop(sessionId: string | null | undefined): Promise<void> {
|
async function sendStop(sessionId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_send_command", {
|
await invoke("remote_send_command", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -122,7 +122,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Send next track command
|
* Send next track command
|
||||||
*/
|
*/
|
||||||
async function sendNext(sessionId: string | null | undefined): Promise<void> {
|
async function sendNext(sessionId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_send_command", {
|
await invoke("remote_send_command", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -138,7 +138,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Send previous track command
|
* Send previous track command
|
||||||
*/
|
*/
|
||||||
async function sendPrevious(sessionId: string | null | undefined): Promise<void> {
|
async function sendPrevious(sessionId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_send_command", {
|
await invoke("remote_send_command", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -154,7 +154,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Seek to position (in ticks)
|
* Seek to position (in ticks)
|
||||||
*/
|
*/
|
||||||
async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> {
|
async function sendSeek(sessionId: string, positionTicks: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_session_seek", {
|
await invoke("remote_session_seek", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -170,7 +170,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Set volume (0-100)
|
* Set volume (0-100)
|
||||||
*/
|
*/
|
||||||
async function sendVolume(sessionId: string | null | undefined, volume: number): Promise<void> {
|
async function sendVolume(sessionId: string, volume: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_session_set_volume", {
|
await invoke("remote_session_set_volume", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -186,7 +186,7 @@ function createSessionsStore() {
|
|||||||
/**
|
/**
|
||||||
* Toggle mute
|
* Toggle mute
|
||||||
*/
|
*/
|
||||||
async function sendToggleMute(sessionId: string | null | undefined): Promise<void> {
|
async function sendToggleMute(sessionId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke("remote_send_command", {
|
await invoke("remote_send_command", {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -203,7 +203,7 @@ function createSessionsStore() {
|
|||||||
* Play item(s) on remote session
|
* Play item(s) on remote session
|
||||||
*/
|
*/
|
||||||
async function playOnSession(
|
async function playOnSession(
|
||||||
sessionId: string | null | undefined,
|
sessionId: string,
|
||||||
itemIds: string[],
|
itemIds: string[],
|
||||||
startIndex = 0
|
startIndex = 0
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
|
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
|
||||||
* @returns Formatted duration string or empty string if no ticks
|
* @returns Formatted duration string or empty string if no ticks
|
||||||
*/
|
*/
|
||||||
export function formatDuration(ticks?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
export function formatDuration(ticks?: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
||||||
if (!ticks) return "";
|
if (!ticks) return "";
|
||||||
|
|
||||||
// Jellyfin uses 10,000,000 ticks per second
|
// Jellyfin uses 10,000,000 ticks per second
|
||||||
|
|||||||
@ -25,8 +25,8 @@ export function secondsToTicks(seconds: number): number {
|
|||||||
* @param ticks - Time in Jellyfin ticks
|
* @param ticks - Time in Jellyfin ticks
|
||||||
* @returns Time in seconds
|
* @returns Time in seconds
|
||||||
*/
|
*/
|
||||||
export function ticksToSeconds(ticks: number | null | undefined): number {
|
export function ticksToSeconds(ticks: number): number {
|
||||||
return (ticks ?? 0) / TICKS_PER_SECOND;
|
return ticks / TICKS_PER_SECOND;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -456,7 +456,7 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#if item.people.some(p => p.type === "Director")}
|
{#if item.people.some(p => p.type === "Director")}
|
||||||
<CrewLinks
|
<CrewLinks
|
||||||
people={item.people ?? undefined}
|
people={item.people}
|
||||||
roleFilter={["Director"]}
|
roleFilter={["Director"]}
|
||||||
label="Directed by"
|
label="Directed by"
|
||||||
maxShow={3}
|
maxShow={3}
|
||||||
@ -465,7 +465,7 @@
|
|||||||
|
|
||||||
{#if item.people.some(p => p.type === "Writer")}
|
{#if item.people.some(p => p.type === "Writer")}
|
||||||
<CrewLinks
|
<CrewLinks
|
||||||
people={item.people ?? undefined}
|
people={item.people}
|
||||||
roleFilter={["Writer"]}
|
roleFilter={["Writer"]}
|
||||||
label="Written by"
|
label="Written by"
|
||||||
maxShow={3}
|
maxShow={3}
|
||||||
@ -474,7 +474,7 @@
|
|||||||
|
|
||||||
{#if item.people.some(p => p.type === "Composer")}
|
{#if item.people.some(p => p.type === "Composer")}
|
||||||
<CrewLinks
|
<CrewLinks
|
||||||
people={item.people ?? undefined}
|
people={item.people}
|
||||||
roleFilter={["Composer"]}
|
roleFilter={["Composer"]}
|
||||||
label="Music by"
|
label="Music by"
|
||||||
maxShow={2}
|
maxShow={2}
|
||||||
@ -486,13 +486,13 @@
|
|||||||
<!-- Genre Tags -->
|
<!-- Genre Tags -->
|
||||||
{#if item.genres?.length}
|
{#if item.genres?.length}
|
||||||
<div>
|
<div>
|
||||||
<GenreTags genres={item.genres ?? undefined} maxShow={6} />
|
<GenreTags genres={item.genres} maxShow={6} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Cast Section - for Movies, Series, and Episodes -->
|
<!-- Cast Section - for Movies, Series, and Episodes -->
|
||||||
{#if (item.type === "Movie" || item.type === "Series" || item.type === "Episode") && item.people?.length}
|
{#if (item.type === "Movie" || item.type === "Series" || item.type === "Episode") && item.people?.length}
|
||||||
<CastSection people={item.people ?? undefined} />
|
<CastSection people={item.people} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Related Items Section - for Movies and Series -->
|
<!-- Related Items Section - for Movies and Series -->
|
||||||
@ -500,8 +500,8 @@
|
|||||||
<RelatedItemsSection
|
<RelatedItemsSection
|
||||||
currentItemId={item.id}
|
currentItemId={item.id}
|
||||||
itemType={item.type}
|
itemType={item.type}
|
||||||
genres={item.genres ?? undefined}
|
genres={item.genres}
|
||||||
people={item.people ?? undefined}
|
people={item.people}
|
||||||
limit={12}
|
limit={12}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@ -528,7 +528,7 @@
|
|||||||
<RelatedItemsSection
|
<RelatedItemsSection
|
||||||
currentItemId={item.id}
|
currentItemId={item.id}
|
||||||
itemType="MusicAlbum"
|
itemType="MusicAlbum"
|
||||||
genres={item.genres ?? undefined}
|
genres={item.genres}
|
||||||
artistIds={item.artistItems?.map(a => a.id)}
|
artistIds={item.artistItems?.map(a => a.id)}
|
||||||
limit={12}
|
limit={12}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user