Files
jellytau/src-tauri/src/player/android/mod.rs
T
dtourolle a53042fe80 fix(playback): bound the device profile by the audio route's channels (DR-141)
MediaCodecList answers "can this device decode 5.1", which is not the
question that decides whether the user hears anything: a phone decodes an
AC-3 5.1 track happily and still has two channels to play it out of. The
DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to
direct-play the multichannel track to a two-channel sink — silence or
dialogue folded into surround channels that go nowhere, depending on the
device.

Report media3 AudioCapabilities.maxChannelCount for the current route over
JNI alongside the codec lists, and bound the direct-play and transcoding
profiles (and the HLS URL's TranscodingMaxAudioChannels, previously
hardcoded to 2) by it. No codec is ever removed, so a device with genuine
surround output keeps direct-playing it. A missing or zero reading means
"route not yet established", not "no audio", and falls back to stereo.
2026-08-09 15:01:57 +02:00

1547 lines
56 KiB
Rust

//! ExoPlayer backend for Android via JNI.
//!
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jdouble, jfloat, jint};
use jni::{JNIEnv, JavaVM};
use super::autoplay::AutoplayDecision;
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{audio_settings_jni_payload, AudioSettings};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
/// Global reference to the Android Context for accessing app classes
static APP_CONTEXT: OnceLock<GlobalRef> = OnceLock::new();
/// Global event emitter for callbacks from Kotlin
static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
/// Shared state updated by JNI callbacks
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();
/// Global handler for media session commands from Android lockscreen/notification
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
OnceLock::new();
/// Global handler for remote volume changes from Android volume buttons
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
OnceLock::new();
/// Global player controller for autoplay decisions
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();
/// Global storage for detected device codecs
static DETECTED_CODECS: OnceLock<DetectedCodecs> = OnceLock::new();
/// Global playback reporter for dual sync (local DB + server)
static PLAYBACK_REPORTER: OnceLock<Arc<TokioMutex<Option<PlaybackReporter>>>> = OnceLock::new();
/// Global position throttler for progress reporting
static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
/// Detected codec capabilities from Android MediaCodecList
#[derive(Debug, Clone)]
struct DetectedCodecs {
video_codecs: Vec<String>,
audio_codecs: Vec<String>,
/// Channels the *current audio output route* accepts, as reported by
/// media3's `AudioCapabilities`. Distinct from the codec lists: a device
/// decodes 5.1 happily and still has only two channels to play it out of.
/// `None` when the platform had no answer.
max_audio_channels: Option<u32>,
}
impl DetectedCodecs {
/// Create from comma-separated codec strings (from JNI)
fn from_jni_strings(
video_codecs: &str,
audio_codecs: &str,
max_audio_channels: Option<u32>,
) -> Self {
Self {
video_codecs: video_codecs
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect(),
audio_codecs: audio_codecs
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect(),
max_audio_channels,
}
}
/// Get video codecs as comma-separated string (for DeviceProfile)
fn video_codecs_string(&self) -> String {
self.video_codecs.join(",")
}
/// Get audio codecs as comma-separated string (for DeviceProfile)
fn audio_codecs_string(&self) -> String {
self.audio_codecs.join(",")
}
}
/// Public function to get detected codecs (for use in repository layer).
///
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
/// channels the current audio output can actually voice, which bounds what the
/// server may direct-play.
pub fn get_detected_codecs() -> Option<(String, String, Option<u32>)> {
DETECTED_CODECS.get().map(|codecs| {
(
codecs.video_codecs_string(),
codecs.audio_codecs_string(),
codecs.max_audio_channels,
)
})
}
/// Trait for handling media commands from Android MediaSession.
///
/// This is implemented by the application to receive commands from
/// lockscreen controls, notification buttons, and Bluetooth devices.
pub trait MediaCommandHandler: Send + Sync {
/// Handle a media command.
///
/// Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
fn on_command(&self, command: &str);
}
/// Trait for handling remote volume changes when in remote playback mode.
///
/// This is implemented by the application to forward volume button presses
/// to the remote Jellyfin session.
pub trait RemoteVolumeHandler: Send + Sync {
/// Handle a remote volume change command.
///
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
/// @param command The volume command
/// @param volume The volume level (0-100)
fn on_remote_volume_change(&self, command: &str, volume: i32);
}
/// Set the global media command handler.
///
/// This should be called during app initialization after creating the PlayerController.
pub fn set_media_command_handler(handler: Arc<dyn MediaCommandHandler + Send + Sync>) {
let _ = MEDIA_COMMAND_HANDLER.set(handler);
}
/// Set the global player controller.
///
/// This should be called during app initialization after creating the PlayerController.
pub fn set_player_controller(controller: Arc<TokioMutex<super::PlayerController>>) {
let _ = PLAYER_CONTROLLER.set(controller);
}
/// Set the global remote volume handler.
///
/// This should be called during app initialization to handle remote volume changes.
pub fn set_remote_volume_handler(handler: Arc<dyn RemoteVolumeHandler + Send + Sync>) {
let _ = REMOTE_VOLUME_HANDLER.set(handler);
}
/// State tracking for ExoPlayer
#[derive(Default)]
struct ExoPlayerState {
state: PlayerState,
position: f64,
duration: Option<f64>,
volume: f32,
is_loaded: bool,
current_media: Option<MediaItem>,
/// Last applied audio settings. Unlike the fields above (which JNI callbacks
/// push *in*), this is commanded *out* — audio settings are never reported
/// by the player, so this is the authoritative copy for `audio_settings()`.
audio_settings: AudioSettings,
}
impl ExoPlayerState {
fn new() -> Self {
Self {
state: PlayerState::Idle,
position: 0.0,
duration: None,
volume: 1.0,
is_loaded: false,
current_media: None,
audio_settings: AudioSettings::default(),
}
}
}
/// ExoPlayer-based player backend for Android.
pub struct ExoPlayerBackend {
/// Reference to the JellyTauPlayer Kotlin object
player_ref: GlobalRef,
/// Shared state
shared_state: Arc<Mutex<ExoPlayerState>>,
}
impl ExoPlayerBackend {
/// Initialize the ExoPlayer backend.
///
/// This must be called from the Android main thread with the Activity context.
///
/// # Arguments
/// * `env` - JNI environment
/// * `context` - Android Context (Activity or Application)
/// * `event_emitter` - Event emitter for frontend notifications
/// * `playback_reporter` - Playback reporter for dual sync
/// * `position_throttler` - Event throttler for position updates
pub fn new(
env: &mut JNIEnv,
context: &JObject,
event_emitter: SharedEventEmitter,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> Result<Self, PlayerError> {
// Store the JavaVM for callbacks
let vm = env
.get_java_vm()
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JavaVM: {}", e)))?;
let _ = JAVA_VM.set(vm);
// Store the Context as a global reference for later use
let context_global = env.new_global_ref(context).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
})?;
let _ = APP_CONTEXT.set(context_global);
// Store the event emitter
let _ = EVENT_EMITTER.set(event_emitter);
// Store the playback reporter and throttler
let _ = PLAYBACK_REPORTER.set(playback_reporter);
let _ = POSITION_THROTTLER.set(position_throttler);
// Create shared state
let shared_state = Arc::new(Mutex::new(ExoPlayerState::new()));
let _ = SHARED_STATE.set(shared_state.clone());
// Get the ClassLoader from the Context to load app classes
// This is necessary because find_class() uses the system class loader
// which doesn't have access to app classes when called from native threads
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
.l()
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
})?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
})?;
let player_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&player_class_name.into())],
)
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
})?
.l()
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
})?;
// Cast to JClass for static method calls
let player_class = JClass::from(player_class_obj);
// Call JellyTauPlayer.initialize(context)
env.call_static_method(
&player_class,
"initialize",
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
})?;
// Get the singleton instance
let player_obj = env
.call_static_method(
&player_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
&[],
)
.map_err(|e| {
PlayerError::playback_failed(format!(
"Failed to get JellyTauPlayer instance: {}",
e
))
})?
.l()
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
})?;
// Create a global reference to keep the player alive
let player_ref = env.new_global_ref(player_obj).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
})?;
Ok(Self {
player_ref,
shared_state,
})
}
/// Call a void method on the player with no arguments
fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(&self.player_ref, method, "()V", &[])
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
})?;
Ok(())
}
}
impl Drop for ExoPlayerBackend {
fn drop(&mut self) {
let _ = self.call_player_method("release");
}
}
impl PlayerBackend for ExoPlayerBackend {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
let url = media.playback_url();
let media_id = media.id.clone();
let title = media.title.clone();
let artist = media.artist.clone();
let album = media.album.clone();
let artwork_url = media.artwork_url.clone();
// Convert duration from seconds to milliseconds
let duration_ms = media.duration.map(|d| (d * 1000.0) as i64).unwrap_or(0);
// Update local state
{
let mut state = self.shared_state.lock_safe();
state.current_media = Some(media.clone());
state.state = PlayerState::Loading {
media: media.clone(),
};
state.position = 0.0;
state.is_loaded = false;
}
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
// Create JNI strings for required parameters
let url_jstring = env.new_string(&url).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
})?;
let media_id_jstring = env.new_string(&media_id).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
})?;
let title_jstring = env.new_string(&title).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create title string: {}", e))
})?;
// Create JNI strings for optional parameters (null if None)
let artist_jstring = match &artist {
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
})?),
None => None,
};
let album_jstring = match &album {
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create album string: {}", e))
})?),
None => None,
};
let artwork_jstring = match &artwork_url {
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
})?),
None => None,
};
// Create null object binding for use in match arms
let null_obj = JObject::null();
// Get JValue for nullable strings - convert Option<JString> to JValue::Object
let artist_value = match &artist_jstring {
Some(s) => JValue::Object(s.as_ref()),
None => JValue::Object(&null_obj),
};
let album_value = match &album_jstring {
Some(s) => JValue::Object(s.as_ref()),
None => JValue::Object(&null_obj),
};
let artwork_value = match &artwork_jstring {
Some(s) => JValue::Object(s.as_ref()),
None => JValue::Object(&null_obj),
};
// Determine media type string for JNI
let media_type_str = match media.media_type {
MediaType::Video => "video",
MediaType::Audio => "audio",
};
let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
})?;
// Serialize subtitles to JSON for passing to Kotlin
let subtitles_json =
serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
})?;
// Call loadWithMetadata for MediaSession support (lockscreen controls)
debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
&url, &media_id, &title, &artist, &album, duration_ms, media_type_str, &subtitles_json);
let result = env.call_method(
&self.player_ref,
"loadWithMetadata",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JLjava/lang/String;Ljava/lang/String;)V",
&[
JValue::Object(&url_jstring),
JValue::Object(&media_id_jstring),
JValue::Object(&title_jstring),
artist_value,
album_value,
artwork_value,
JValue::Long(duration_ms),
JValue::Object(&media_type_jstring),
JValue::Object(&subtitles_jstring),
],
);
if let Err(e) = result {
// Check for pending exceptions
if let Ok(true) = env.exception_check() {
env.exception_describe().ok();
env.exception_clear().ok();
}
return Err(PlayerError::playback_failed(format!(
"Failed to call loadWithMetadata: {}",
e
)));
}
debug!("[Android] Successfully called loadWithMetadata");
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.call_player_method("play")
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.call_player_method("pause")
}
fn stop(&mut self) -> Result<(), PlayerError> {
{
let mut state = self.shared_state.lock_safe();
state.state = PlayerState::Idle;
state.is_loaded = false;
state.current_media = None;
state.position = 0.0;
state.duration = None;
}
self.call_player_method("stop")
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(
&self.player_ref,
"seek",
"(D)V",
&[JValue::Double(position)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call seek: {}", e)))?;
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(
&self.player_ref,
"setVolume",
"(FZ)V",
&[JValue::Float(clamped), JValue::Bool(0)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
self.shared_state.lock_safe().volume = clamped;
Ok(())
}
fn position(&self) -> f64 {
self.shared_state.lock_safe().position
}
fn duration(&self) -> Option<f64> {
self.shared_state.lock_safe().duration
}
fn state(&self) -> PlayerState {
self.shared_state.lock_safe().state.clone()
}
fn volume(&self) -> f32 {
self.shared_state.lock_safe().volume
}
/// Apply audio settings to ExoPlayer (equalizer, normalization, gapless).
///
/// Sent as JSON rather than a wide JNI signature so new fields do not change
/// the method signature — the same approach `load()` uses for subtitles. The
/// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio
/// session); the canonical band layout and preset curves stay in Rust.
///
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
let json = audio_settings_jni_payload(settings).map_err(|e| {
PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e))
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
let json_jstring = env.new_string(&json).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create settings string: {}", e))
})?;
env.call_method(
&self.player_ref,
"setAudioSettings",
"(Ljava/lang/String;)V",
&[JValue::Object(&json_jstring)],
)
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e))
})?;
// Store the sanitised form so audio_settings() reflects what was applied,
// not what was requested.
self.shared_state.lock_safe().audio_settings = settings
.clone()
.with_crossfade_clamped()
.with_equalizer_normalised();
Ok(())
}
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
fn audio_settings(&self) -> AudioSettings {
self.shared_state.lock_safe().audio_settings.clone()
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(
&self.player_ref,
"setAudioTrack",
"(I)V",
&[JValue::Int(stream_index)],
)
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
})?;
Ok(())
}
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
// Use -1 to disable subtitles if stream_index is None
let index = stream_index.unwrap_or(-1);
env.call_method(
&self.player_ref,
"setSubtitleTrack",
"(I)V",
&[JValue::Int(index)],
)
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
})?;
Ok(())
}
}
// JNI callback functions called from Kotlin
/// Called when position updates during playback.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
_env: JNIEnv,
_class: JClass,
position: jdouble,
duration: jdouble,
) {
// Debug: Log every 10th update to avoid spam
static mut UPDATE_COUNTER: u32 = 0;
unsafe {
UPDATE_COUNTER += 1;
if UPDATE_COUNTER % 10 == 0 {
log::debug!("[Android] Position update {} / {}", position, duration);
}
}
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
}
// Use preserved duration from state, or fall back to the received value
state.duration.unwrap_or(duration)
} else {
duration
};
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position,
duration: duration_to_emit,
});
} else {
log::error!("[Android] WARNING: No event emitter for position update!");
}
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
}
/// Report throttled playback progress to Jellyfin from the Android position
/// callback. No-op until the reporter/throttler are wired (post-login) or when
/// not actively playing.
fn report_android_progress(position: f64) {
let item_id = match SHARED_STATE.get() {
Some(state) => {
let state = state.lock_safe();
if !state.state.is_playing() {
return;
}
match state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
{
Some(id) => id,
None => return,
}
}
None => return,
};
let throttler = match POSITION_THROTTLER.get() {
Some(t) => t,
None => return,
};
if !throttler.should_report(&item_id) {
return;
}
let reporter_arc = match PLAYBACK_REPORTER.get() {
Some(r) => r.clone(),
None => return,
};
let position_ticks = seconds_to_ticks(position);
let item_id_for_task = item_id.clone();
// The reporter is async; the JNI callback is sync. Spawn onto the Tokio
// runtime when present, otherwise a throwaway runtime on a new thread.
let spawn_report = move || async move {
let reporter_guard = reporter_arc.lock().await;
if let Some(reporter) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_for_task.clone(),
position_ticks,
is_paused: false,
};
match reporter.report(operation, true).await {
Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task),
Err(e) => log::warn!("[Android] Failed to report progress: {}", e),
}
}
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(spawn_report());
} else {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(spawn_report());
});
}
throttler.mark_reported(&item_id);
}
/// Called when player state changes.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged(
mut env: JNIEnv,
_class: JClass,
state: JString,
media_id: JString,
) {
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() {
None
} else {
env.get_string(&media_id).map(|s| s.into()).ok()
};
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
match state_str.as_str() {
"playing" => {
shared.state = PlayerState::Playing {
media,
position,
duration,
};
shared.is_loaded = true;
}
"paused" => {
shared.state = PlayerState::Paused {
media,
position,
duration,
};
}
"idle" => {
shared.state = PlayerState::Idle;
shared.is_loaded = false;
}
_ => {}
}
}
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::StateChanged {
state: state_str,
media_id: media_id_opt,
});
}
}
/// Called when media has finished loading.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded(
_env: JNIEnv,
_class: JClass,
duration: jdouble,
) {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
}
/// Called when playback reaches the end.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded(
_env: JNIEnv,
_class: JClass,
) {
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
// Get player controller and handle autoplay decision
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
// Spawn async task to handle autoplay decision
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
Ok(AutoplayDecision::AdvanceToNext) => {
log::debug!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let ctrl = controller.lock().await;
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
match ctrl.next() {
Ok(_) => {
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
// Emit queue changed event so frontend updates UI with new current track
ctrl.emit_queue_changed();
}
Err(e) => {
log::error!("[Autoplay] Failed to advance to next track: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
}
Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
countdown_seconds,
auto_advance,
}) => {
log::info!(
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
countdown_seconds,
auto_advance
);
// Emit popup event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
current_episode: current_episode.clone(),
next_episode: next_episode.clone(),
countdown_seconds,
auto_advance,
});
}
if auto_advance {
// Shared with the frontend-invoked command path
// (player_on_playback_ended) so the two dispatchers cannot
// disagree about how a background audio-only episode
// advances — they did, and the command's copy was missing
// the case entirely. That copy is the one that actually
// decides here: the end reason set at load makes this
// callback's own decision Stop, and the frontend echoes the
// resulting PlaybackEnded back into the command.
controller
.lock()
.await
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
Ok(AutoplayDecision::ResumeStream { position }) => {
// ExoPlayer reported ENDED because the progressive transcode's
// connection dropped, not because the episode finished. This
// is the arm that matters while backgrounded: it needs no
// frontend echo, so the stream re-opens even with the webview
// suspended — and playback never parks in STATE_ENDED, where
// the next lockscreen/Bluetooth play restarts the item at 0:00.
log::info!(
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
position
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
Err(e) => {
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
});
} else {
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
// Fallback: just emit PlaybackEnded event
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
/// Called when buffering state changes.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering(
_env: JNIEnv,
_class: JClass,
percent: jint,
) {
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Buffering {
percent: percent as u8,
});
}
}
/// Called when a playback error occurs.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError(
mut env: JNIEnv,
_class: JClass,
message: JString,
recoverable: jboolean,
) {
let message_str: String = env
.get_string(&message)
.map(|s| s.into())
.unwrap_or_else(|_| "Unknown error".to_string());
let recoverable = recoverable != 0;
// A background audio-only handoff is an mp3 the device was already decoding,
// so a recoverable failure part-way through is the network. Surfacing it as a
// player error stops playback for good (the frontend's handler calls
// player_stop); re-opening the stream where it died is the "buffer and
// resume" this actually is. Everything else keeps reporting the error.
if recoverable {
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
let message_str = message_str.clone();
tauri::async_runtime::spawn(async move {
let resume = controller.lock().await.recoverable_error_resume();
let Some((position, delay_secs)) = resume else {
// Declined here, so report it as NOT recoverable: the frontend
// would otherwise echo it into player_recover_stream and ask
// the same question a second time.
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
return;
};
log::warn!(
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
message_str,
position,
delay_secs
);
// Give a brief outage time to clear before asking the server for
// the stream again; retrying instantly just burns the budget.
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
}
});
return;
}
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable,
});
}
}
/// Called when volume changes.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged(
_env: JNIEnv,
_class: JClass,
volume: jfloat,
muted: jboolean,
) {
if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume;
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::VolumeChanged {
volume,
muted: muted != 0,
});
}
}
// JNI callback for MediaSession commands from JellyTauPlaybackService
/// Called when a media command is received from the Android MediaSession.
///
/// Commands from lockscreen controls, notification buttons, and Bluetooth
/// devices are routed through here to the Rust PlayerController.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
mut env: JNIEnv,
_class: JClass,
command: JString,
) {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
handler.on_command(&command_str);
}
}
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
///
/// This is called when the user presses volume up/down buttons or adjusts volume
/// while in remote playback mode (casting to a Jellyfin session).
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange(
mut env: JNIEnv,
_class: JClass,
command: JString,
volume: jint,
) {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
handler.on_remote_volume_change(&command_str, volume as i32);
}
}
/// JNI callback from Kotlin when codec detection completes.
///
/// Called once during JellyTauPlayer initialization to store the device's
/// hardware codec capabilities for use in DeviceProfile generation.
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected(
mut env: JNIEnv,
_class: JClass,
video_codecs: JString,
audio_codecs: JString,
max_audio_channels: jint,
) {
let video_str: String = env
.get_string(&video_codecs)
.map(|s| s.into())
.unwrap_or_default();
let audio_str: String = env
.get_string(&audio_codecs)
.map(|s| s.into())
.unwrap_or_default();
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
codecs.video_codecs.len(),
codecs.video_codecs_string()
);
log::info!(
"[CodecDetection] Detected {} audio codecs: {}",
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
log::error!("[CodecDetection] Failed to store codecs - already initialized");
}
}
/// Start the JellyTauPlaybackService if not already running.
///
/// This service is required for remote volume control to work.
#[cfg(target_os = "android")]
fn start_playback_service() -> Result<(), String> {
log::info!("[Android] Starting JellyTauPlaybackService");
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
// Get the stored Context
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
// Get the ClassLoader from the Context to load app classes
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let player_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&player_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlayer class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
// Cast to JClass for static method calls
let player_class = JClass::from(player_class_obj);
// Get the singleton instance
let player_obj = env
.call_static_method(
&player_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
&[],
)
.map_err(|e| format!("Failed to get JellyTauPlayer instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
if player_obj.is_null() {
return Err("JellyTauPlayer not initialized".to_string());
}
// Call startPlaybackService() on the player instance
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
.map_err(|e| format!("Failed to start playback service: {}", e))?;
log::info!("[Android] JellyTauPlaybackService start requested");
Ok(())
}
/// Enable remote volume control on Android.
///
/// This switches the MediaSession to remote playback mode, intercepting
/// volume button presses and routing them to the RemoteVolumeHandler.
///
/// @param initial_volume Initial volume level (0-100)
#[cfg(target_os = "android")]
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
log::info!(
"[Android] Enabling remote volume control (volume={})",
initial_volume
);
// Ensure the playback service is started first
start_playback_service()?;
// Give the service a moment to initialize
std::thread::sleep(std::time::Duration::from_millis(100));
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
// Get the stored Context for ClassLoader access
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
// Get the ClassLoader from the Context to load app classes
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
// Cast to JClass for static method calls
let service_class = JClass::from(service_class_obj);
// Get the JellyTauPlaybackService instance
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
if service_obj.is_null() {
return Err("JellyTauPlaybackService not running after start attempt".to_string());
}
// Call enableRemoteVolume(int)
env.call_method(
&service_obj,
"enableRemoteVolume",
"(I)V",
&[JValue::Int(initial_volume)],
)
.map_err(|e| format!("Failed to enable remote volume: {}", e))?;
log::info!("[Android] Remote volume control enabled");
Ok(())
}
/// Disable remote volume control on Android.
///
/// This switches the MediaSession back to local playback mode,
/// allowing system volume buttons to control ExoPlayer volume.
#[cfg(target_os = "android")]
pub fn disable_remote_volume() -> Result<(), String> {
log::info!("[Android] Disabling remote volume control");
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
// Get the stored Context for ClassLoader access
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
// Get the ClassLoader from the Context to load app classes
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
// Cast to JClass for static method calls
let service_class = JClass::from(service_class_obj);
// Get the JellyTauPlaybackService instance
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
if service_obj.is_null() {
return Err("JellyTauPlaybackService not running".to_string());
}
// Call disableRemoteVolume()
env.call_method(&service_obj, "disableRemoteVolume", "()V", &[])
.map_err(|e| format!("Failed to disable remote volume: {}", e))?;
log::info!("[Android] Remote volume control disabled");
Ok(())
}
use crate::player::LockscreenMetadata;
/// Push now-playing metadata and playback state to the Android lockscreen.
///
/// Calls `JellyTauPlaybackService.updateMediaMetadata(...)`. The service must be
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
/// isn't, this is a no-op rather than an error so it can be called freely on
/// every poll tick.
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let service_class = JClass::from(service_class_obj);
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
// Service not running yet (e.g. nothing has played) - nothing to update.
if service_obj.is_null() {
return Ok(());
}
let title = env
.new_string(&meta.title)
.map_err(|e| format!("Failed to create title string: {}", e))?;
let artist = env
.new_string(&meta.artist)
.map_err(|e| format!("Failed to create artist string: {}", e))?;
// album is nullable on the Kotlin side; pass a real String or JObject::null().
let album_obj = match &meta.album {
Some(a) => env
.new_string(a)
.map_err(|e| format!("Failed to create album string: {}", e))?
.into(),
None => jni::objects::JObject::null(),
};
env.call_method(
&service_obj,
"updateMediaMetadata",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZ)V",
&[
JValue::Object(&title.into()),
JValue::Object(&artist.into()),
JValue::Object(&album_obj),
JValue::Long(meta.duration_ms),
JValue::Long(meta.position_ms),
JValue::Bool(meta.is_playing as u8),
],
)
.map_err(|e| format!("Failed to update lockscreen metadata: {}", e))?;
Ok(())
}
/// Set the base position offset (seconds) on the lockscreen MediaSession.
///
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let service_class = JClass::from(service_class_obj);
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
// Service not running yet - nothing to offset.
if service_obj.is_null() {
return Ok(());
}
env.call_method(
&service_obj,
"setPositionOffset",
"(D)V",
&[JValue::Double(offset_seconds)],
)
.map_err(|e| format!("Failed to set position offset: {}", e))?;
Ok(())
}
/// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
Ok(())
}
#[cfg(not(target_os = "android"))]
pub fn disable_remote_volume() -> Result<(), String> {
Ok(())
}