feat(cast): add multi-protocol support for Chromecast, DLNA, and AirPlay

- Update CastPicker to show protocol tabs with device counts
- Register DLNA and AirPlay state and commands in lib.rs
- Add Deserialize derive to DlnaMetadata and AirPlayMetadata
- Fix capabilities file (remove unavailable fs permissions)
- Remove unused IpAddr import in media_server.rs
This commit is contained in:
vicrodh committed 2026-01-10 03:41:11 -06:00
1 parent 762df3e122
commit fbfa067b94
7 files changed
+199 -39

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
# Tauri
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
# Serialization
+1 -11
View File
@@ -8,16 +8,6 @@
"opener:default",
"dialog:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text",
"fs:default",
{
"identifier": "fs:allow-read",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$CACHE/**" },
{ "path": "$CONFIG/**" },
{ "path": "/**" }
]
}
"clipboard-manager:allow-write-text"
]
}
+2 -2
View File
@@ -1,11 +1,11 @@
//! AirPlay device connection and streaming (placeholder)
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::cast::airplay::{AirPlayError, DiscoveredAirPlayDevice};
/// Metadata for AirPlay playback
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirPlayMetadata {
pub title: String,
pub artist: String,
+2 -2
View File
@@ -1,11 +1,11 @@
//! DLNA device connection and playback (placeholder)
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::cast::dlna::{DiscoveredDlnaDevice, DlnaError};
/// Metadata for DLNA playback
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DlnaMetadata {
pub title: String,
pub artist: String,
+1 -1
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::net::{IpAddr, SocketAddr, UdpSocket};
use std::net::{SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
+32 -2
View File
@@ -96,9 +96,13 @@ pub fn run() {
let library_state = library::init_library_state()
.expect("Failed to initialize library database");
// Initialize casting state
// Initialize casting state (Chromecast, DLNA, AirPlay)
let cast_state = cast::CastState::new()
.expect("Failed to initialize casting state");
.expect("Failed to initialize Chromecast state");
let dlna_state = cast::dlna::commands::DlnaState::new()
.expect("Failed to initialize DLNA state");
let airplay_state = cast::airplay::commands::AirPlayState::new()
.expect("Failed to initialize AirPlay state");
// Initialize download cache state
let download_cache_state = download_cache::DownloadCacheState::new()
@@ -111,6 +115,8 @@ pub fn run() {
.manage(AppState::new())
.manage(library_state)
.manage(cast_state)
.manage(dlna_state)
.manage(airplay_state)
.manage(download_cache_state)
.invoke_handler(tauri::generate_handler![
// Auth commands
@@ -236,6 +242,30 @@ pub fn run() {
cast::commands::cast_stop,
cast::commands::cast_seek,
cast::commands::cast_set_volume,
// DLNA casting commands
cast::dlna::commands::dlna_start_discovery,
cast::dlna::commands::dlna_stop_discovery,
cast::dlna::commands::dlna_get_devices,
cast::dlna::commands::dlna_connect,
cast::dlna::commands::dlna_disconnect,
cast::dlna::commands::dlna_get_status,
cast::dlna::commands::dlna_load_media,
cast::dlna::commands::dlna_play,
cast::dlna::commands::dlna_pause,
cast::dlna::commands::dlna_stop,
cast::dlna::commands::dlna_set_volume,
// AirPlay casting commands
cast::airplay::commands::airplay_start_discovery,
cast::airplay::commands::airplay_stop_discovery,
cast::airplay::commands::airplay_get_devices,
cast::airplay::commands::airplay_connect,
cast::airplay::commands::airplay_disconnect,
cast::airplay::commands::airplay_get_status,
cast::airplay::commands::airplay_load_media,
cast::airplay::commands::airplay_play,
cast::airplay::commands::airplay_pause,
cast::airplay::commands::airplay_stop,
cast::airplay::commands::airplay_set_volume,
// Download cache commands
download_cache::commands::download_track,
download_cache::commands::is_track_downloaded,
+160 -20
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount, onDestroy } from 'svelte';
import { X, Cast, Loader2, Monitor, Wifi } from 'lucide-svelte';
import { X, Cast, Loader2, Monitor, Wifi, Tv, Speaker } from 'lucide-svelte';
interface Props {
isOpen: boolean;
onClose: () => void;
onConnect: (deviceId: string) => void;
onConnect: (deviceId: string, protocol: CastProtocol) => void;
}
type CastProtocol = 'chromecast' | 'dlna' | 'airplay';
interface CastDevice {
id: string;
name: string;
@@ -18,11 +20,22 @@
let { isOpen, onClose, onConnect }: Props = $props();
let devices = $state<CastDevice[]>([]);
let activeProtocol = $state<CastProtocol>('chromecast');
let chromecastDevices = $state<CastDevice[]>([]);
let dlnaDevices = $state<CastDevice[]>([]);
let airplayDevices = $state<CastDevice[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let discoveryStarted = $state(false);
const devices = $derived(() => {
switch (activeProtocol) {
case 'chromecast': return chromecastDevices;
case 'dlna': return dlnaDevices;
case 'airplay': return airplayDevices;
}
});
onMount(() => {
if (isOpen) {
startDiscovery();
@@ -49,7 +62,12 @@
discoveryStarted = true;
try {
await invoke('cast_start_discovery');
// Start all discovery protocols in parallel
await Promise.allSettled([
invoke('cast_start_discovery'),
invoke('dlna_start_discovery'),
invoke('airplay_start_discovery')
]);
// Poll for devices
pollDevices();
} catch (err) {
@@ -61,7 +79,11 @@
async function stopDiscovery() {
discoveryStarted = false;
try {
await invoke('cast_stop_discovery');
await Promise.allSettled([
invoke('cast_stop_discovery'),
invoke('dlna_stop_discovery'),
invoke('airplay_stop_discovery')
]);
} catch (err) {
console.error('Failed to stop discovery:', err);
}
@@ -71,8 +93,22 @@
if (!discoveryStarted) return;
try {
const result = await invoke<CastDevice[]>('cast_get_devices');
devices = result;
// Poll all protocols in parallel
const [chromecast, dlna, airplay] = await Promise.allSettled([
invoke<CastDevice[]>('cast_get_devices'),
invoke<CastDevice[]>('dlna_get_devices'),
invoke<CastDevice[]>('airplay_get_devices')
]);
if (chromecast.status === 'fulfilled') {
chromecastDevices = chromecast.value;
}
if (dlna.status === 'fulfilled') {
dlnaDevices = dlna.value;
}
if (airplay.status === 'fulfilled') {
airplayDevices = airplay.value;
}
} catch (err) {
console.error('Failed to get devices:', err);
}
@@ -87,18 +123,44 @@
async function handleConnect(device: CastDevice) {
try {
await invoke('cast_connect', { deviceId: device.id });
onConnect(device.id);
switch (activeProtocol) {
case 'chromecast':
await invoke('cast_connect', { deviceId: device.id });
break;
case 'dlna':
await invoke('dlna_connect', { deviceId: device.id });
break;
case 'airplay':
await invoke('airplay_connect', { deviceId: device.id });
break;
}
onConnect(device.id, activeProtocol);
onClose();
} catch (err) {
error = String(err);
}
}
function getProtocolIcon(protocol: CastProtocol) {
switch (protocol) {
case 'chromecast': return Cast;
case 'dlna': return Tv;
case 'airplay': return Speaker;
}
}
function getDeviceCount(protocol: CastProtocol): number {
switch (protocol) {
case 'chromecast': return chromecastDevices.length;
case 'dlna': return dlnaDevices.length;
case 'airplay': return airplayDevices.length;
}
}
</script>
{#if isOpen}
<div class="overlay" onclick={onClose} role="presentation">
<div class="picker" onclick={(e) => e.stopPropagation()} role="dialog">
<div class="overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()} role="presentation">
<div class="picker" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" tabindex="-1">
<div class="header">
<h3>Cast to Device</h3>
<button class="close-btn" onclick={onClose}>
@@ -106,8 +168,45 @@
</button>
</div>
<!-- Protocol Tabs -->
<div class="protocol-tabs">
<button
class="protocol-tab"
class:active={activeProtocol === 'chromecast'}
onclick={() => activeProtocol = 'chromecast'}
>
<Cast size={16} />
<span>Chromecast</span>
{#if chromecastDevices.length > 0}
<span class="count">{chromecastDevices.length}</span>
{/if}
</button>
<button
class="protocol-tab"
class:active={activeProtocol === 'dlna'}
onclick={() => activeProtocol = 'dlna'}
>
<Tv size={16} />
<span>DLNA</span>
{#if dlnaDevices.length > 0}
<span class="count">{dlnaDevices.length}</span>
{/if}
</button>
<button
class="protocol-tab"
class:active={activeProtocol === 'airplay'}
onclick={() => activeProtocol = 'airplay'}
>
<Speaker size={16} />
<span>AirPlay</span>
{#if airplayDevices.length > 0}
<span class="count">{airplayDevices.length}</span>
{/if}
</button>
</div>
<div class="content">
{#if loading && devices.length === 0}
{#if loading && devices().length === 0}
<div class="loading">
<Loader2 size={32} class="spin" />
<p>Searching for devices...</p>
@@ -116,22 +215,22 @@
<div class="error">
<p>{error}</p>
</div>
{:else if devices.length === 0}
{:else if devices().length === 0}
<div class="empty">
<Wifi size={32} />
<p>No devices found</p>
<p class="hint">Make sure your Chromecast is on the same network</p>
<p>No {activeProtocol === 'chromecast' ? 'Chromecast' : activeProtocol === 'dlna' ? 'DLNA' : 'AirPlay'} devices found</p>
<p class="hint">Make sure devices are on the same network</p>
</div>
{:else}
<div class="devices">
{#each devices as device}
{#each devices() as device}
<button class="device" onclick={() => handleConnect(device)}>
<Monitor size={24} />
<div class="device-info">
<span class="device-name">{device.name}</span>
<span class="device-ip">{device.ip}</span>
</div>
<Cast size={20} class="cast-icon" />
<svelte:component this={getProtocolIcon(activeProtocol)} size={20} class="cast-icon" />
</button>
{/each}
</div>
@@ -153,8 +252,8 @@
}
.picker {
width: 360px;
max-height: 400px;
width: 400px;
max-height: 500px;
background-color: var(--bg-secondary);
border-radius: 12px;
overflow: hidden;
@@ -189,9 +288,50 @@
background-color: rgba(255, 255, 255, 0.1);
}
.protocol-tabs {
display: flex;
padding: 8px;
gap: 4px;
border-bottom: 1px solid var(--bg-tertiary);
}
.protocol-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 12px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.protocol-tab:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.protocol-tab.active {
background-color: var(--accent-primary);
color: white;
}
.protocol-tab .count {
background-color: rgba(255, 255, 255, 0.2);
padding: 2px 6px;
border-radius: 10px;
font-size: 11px;
}
.content {
padding: 16px;
max-height: 300px;
max-height: 350px;
overflow-y: auto;
}