Compare commits

...

3 Commits

Author SHA1 Message Date
6146d70bc5 Linux: render video via HTML5 WebView instead of the MPV backend
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 1m37s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 22s
- online.rs: on Linux, advertise only WebView-decodable codecs (h264 video;
  aac/mp3/opus/vorbis/flac audio) in PlaybackInfo so Jellyfin transcodes
  HEVC/AV1/VP9/etc. to h264 HLS for the WebKitGTK <video> element.
- player: on Linux, don't load video into MPV (no embedded window — it would
  start a redundant decode the frontend immediately stops). Add
  PlayerController::set_current_item to keep queue/UI/remote-transfer state in
  sync without loading the item into the playback backend.
2026-06-20 19:31:57 +02:00
ea342d76e3 Fix download + casting breakages from the specta migration
Casting: restore camelCase serialization on SessionInfo/NowPlayingItem/PlayState
(container rename_all = "camelCase" + per-field PascalCase serde aliases so Jellyfin
PascalCase still deserializes). Bindings and the existing frontend are camelCase
again — casting works with no frontend session changes.

Downloads: migrate DownloadButton.svelte and downloads.ts to the typed
commands.downloadItemAndStart / downloadItem / downloadVideo wrappers (request
objects), matching the bundled backend signatures.

Tooling:
- Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T>
  and throw (drop-in for invoke()).
- Disambiguate specta name collisions: player MediaItem/MediaSource ->
  PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo.
- Regenerate bindings.ts; svelte-check passes (0 errors).
2026-06-20 19:27:29 +02:00
7d07b38d50 Exclude generated bindings.ts from Tailwind content scan
Tailwind v4 auto-scans all source files; the 3000-line generated bindings.ts has
no CSS classes and a large generated TS file can confuse class detection. Add an
@source not exclusion.
2026-06-20 18:38:59 +02:00
11 changed files with 630 additions and 1515 deletions

View File

@ -12,6 +12,7 @@ pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[specta(rename = "AuthServerInfo")]
pub struct ServerInfo {
pub name: String,
pub version: String,

View File

@ -411,9 +411,22 @@ pub async fn player_play_item(
}
let controller = player.0.lock().await;
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
// get_player_status -> use_html5_element). The MPV backend has no embedded
// window, so loading the stream into it would only start a redundant decode
// (and the frontend would immediately stop it). Only load into the native
// backend on platforms that actually render video through it (e.g. Android).
#[cfg(not(target_os = "linux"))]
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
// Keep the queue in sync for UI/remote-transfer without starting MPV.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// Emit queue changed event
controller.emit_queue_changed();

View File

@ -382,71 +382,102 @@ fn default_true() -> bool {
/// Session information from Jellyfin
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
#[serde(default)]
#[serde(alias = "Id")]
pub id: Option<String>,
#[serde(default)]
#[serde(alias = "UserId")]
pub user_id: Option<String>,
#[serde(default)]
#[serde(alias = "UserName")]
pub user_name: Option<String>,
#[serde(default)]
#[serde(alias = "Client")]
pub client: Option<String>,
#[serde(default)]
#[serde(alias = "DeviceName")]
pub device_name: Option<String>,
#[serde(default)]
#[serde(alias = "DeviceId")]
pub device_id: Option<String>,
#[serde(default)]
#[serde(alias = "ApplicationVersion")]
pub application_version: Option<String>,
#[serde(default)]
#[serde(alias = "IsActive")]
pub is_active: Option<bool>,
#[serde(default)]
#[serde(alias = "SupportsMediaControl")]
pub supports_media_control: Option<bool>,
#[serde(default = "default_true")]
#[serde(alias = "SupportsRemoteControl")]
pub supports_remote_control: bool,
#[serde(default)]
#[serde(alias = "NowPlayingItem")]
pub now_playing_item: Option<NowPlayingItem>,
#[serde(default)]
#[serde(alias = "PlayState")]
pub play_state: Option<PlayState>,
#[serde(default)]
#[serde(alias = "PlayableMediaTypes")]
pub playable_media_types: Option<Vec<String>>,
#[serde(default)]
#[serde(alias = "SupportedCommands")]
pub supported_commands: Option<Vec<String>>,
}
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
#[serde(rename_all = "camelCase")]
pub struct NowPlayingItem {
#[serde(alias = "Id")]
pub id: Option<String>,
#[serde(alias = "Name")]
pub name: Option<String>,
#[serde(alias = "RunTimeTicks")]
pub run_time_ticks: Option<i64>,
#[serde(alias = "Album")]
pub album: Option<String>,
#[serde(alias = "AlbumId")]
pub album_id: Option<String>,
#[serde(alias = "AlbumArtist")]
pub album_artist: Option<String>,
#[serde(alias = "Artists")]
pub artists: Option<Vec<String>>,
#[serde(alias = "ImageTags")]
pub image_tags: Option<std::collections::HashMap<String, String>>,
#[serde(alias = "PrimaryImageTag")]
pub primary_image_tag: Option<String>,
#[serde(alias = "AlbumPrimaryImageTag")]
pub album_primary_image_tag: Option<String>,
#[serde(rename = "Type")]
pub item_type: Option<String>,
}
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
#[serde(rename_all = "camelCase")]
pub struct PlayState {
#[serde(default)]
#[serde(alias = "PositionTicks")]
pub position_ticks: Option<i64>,
#[serde(default)]
#[serde(alias = "CanSeek")]
pub can_seek: Option<bool>,
#[serde(default)]
#[serde(alias = "IsPaused")]
pub is_paused: Option<bool>,
#[serde(default)]
#[serde(alias = "IsMuted")]
pub is_muted: Option<bool>,
#[serde(default)]
#[serde(alias = "VolumeLevel")]
pub volume_level: Option<i32>,
#[serde(default)]
#[serde(alias = "RepeatMode")]
pub repeat_mode: Option<String>,
#[serde(default)]
#[serde(alias = "ShuffleMode")]
pub shuffle_mode: Option<String>,
}

View File

@ -351,6 +351,9 @@ fn create_player_backend(
/// bindings-export test so the TypeScript bindings always match the handler.
fn specta_builder() -> Builder<tauri::Wry> {
Builder::<tauri::Wry>::new()
// Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,

View File

@ -42,6 +42,7 @@ pub struct SubtitleTrack {
/// TRACES: UR-003, UR-004 | DR-002
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[specta(rename = "PlayerMediaItem")]
pub struct MediaItem {
/// Unique identifier
pub id: String,
@ -116,6 +117,7 @@ pub enum MediaType {
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
#[specta(rename = "PlayerMediaSource")]
pub enum MediaSource {
/// Streaming from Jellyfin server
Remote {

View File

@ -212,6 +212,22 @@ impl PlayerController {
Ok(())
}
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {

View File

@ -859,7 +859,18 @@ impl MediaRepository for OnlineRepository {
("h264,hevc".to_string(), "aac,mp3".to_string())
});
#[cfg(not(target_os = "android"))]
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = (
"h264".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = (
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),

View File

@ -1,5 +1,10 @@
@import "tailwindcss";
/* Exclude the generated tauri-specta bindings from Tailwind's content scan.
It contains no CSS classes (only TS types/command wrappers), and scanning a
large generated file can confuse Tailwind v4's automatic class detection. */
@source not "./lib/api/bindings.ts";
/* Custom theme variables for JellyTau */
@theme {
--color-jellyfin: #00a4dc;

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import DownloadButtonCore from "./DownloadButtonCore.svelte";
import type { DownloadState } from "./DownloadButtonCore.svelte";
@ -84,14 +85,14 @@
console.log(" Target directory:", targetDir);
// Queue and start download in single atomic operation
const downloadId = await invoke<number>("download_item_and_start", {
const downloadId = await commands.downloadItemAndStart({
itemId,
userId,
streamUrl,
targetDir,
itemName: itemName || undefined,
artistName: artistName || undefined,
albumName: albumName || undefined,
itemName: itemName || null,
artistName: artistName || null,
albumName: albumName || null,
});
console.log(" Download queued and started with ID:", downloadId);

View File

@ -2,6 +2,7 @@
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
import { writable, derived, get } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
import { commands } from '$lib/api/bindings';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
// Event listener state
@ -150,15 +151,16 @@ function createDownloadsStore() {
): Promise<number> {
try {
console.log('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName });
const downloadId = await invoke<number>('download_item', {
const downloadId = await commands.downloadItem({
itemId,
userId,
filePath,
mimeType,
priority,
itemName,
artistName,
albumName
mimeType: mimeType ?? null,
priority: priority ?? null,
itemName: itemName ?? null,
artistName: artistName ?? null,
albumName: albumName ?? null,
expectedSize: null
});
console.log(' Got download ID from backend:', downloadId);
@ -222,18 +224,18 @@ function createDownloadsStore() {
qualityPreset,
seriesName
});
const downloadId = await invoke<number>('download_video', {
const downloadId = await commands.downloadVideo({
itemId,
userId,
filePath,
mimeType,
priority,
itemName,
qualityPreset,
seriesName,
seasonName,
episodeNumber,
seasonNumber
mimeType: mimeType ?? null,
priority: priority ?? null,
itemName: itemName ?? null,
qualityPreset: qualityPreset ?? null,
seriesName: seriesName ?? null,
seasonName: seasonName ?? null,
episodeNumber: episodeNumber ?? null,
seasonNumber: seasonNumber ?? null
});
console.log(' Got download ID from backend:', downloadId);