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:
1 parent
762df3e122
commit
fbfa067b94
7 files changed
+199
-39
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user