Wire up playback reporting, fix duration flash, hide video from audio mini player
Playback reporting (position sync / resume-on-another-device): - player_configure_jellyfin now builds a PlaybackReporter sharing the player controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every auth path (login/restore/reauth); previously they never did. - The PlaybackReporterWrapper now shares the same Arc the controller and MPV progress loop report through, instead of a dead parallel Option. - Android position callbacks now emit throttled progress reports (30s/item), mirroring the MPV backend. Duration flash on pause: - resolveDuration() prefers the live store duration for the already-loaded track over the runTimeTicks estimate, so pausing no longer clobbers the slider's max to 0 when runTimeTicks is missing. Video leaking into audio mini player: - isVideoItem() also checks the backend PlayerMediaItem mediaType discriminator, so a video started via player_play_item (no Jellyfin `type`, mediaType "video") no longer surfaces in the audio mini player. Middle-truncation of long media names: - New truncateMiddle util applied to track/episode/card/mini-player titles so distinguishing tails (episode numbers, suffixes) stay visible. Adds regression tests for the duration and mini-player fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dcee342c47
commit
342f95cac1
@ -1826,9 +1826,10 @@ pub async fn player_get_cache_config(
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_configure_jellyfin(
|
pub async fn player_configure_jellyfin(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
_user_id: String,
|
user_id: String,
|
||||||
device_id: String,
|
device_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
|
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
|
||||||
@ -1839,12 +1840,31 @@ pub async fn player_configure_jellyfin(
|
|||||||
device_id,
|
device_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = JellyfinClient::new(config)?;
|
// Legacy client (used for remote session control / casting).
|
||||||
|
let client = JellyfinClient::new(config.clone())?;
|
||||||
|
|
||||||
|
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
|
||||||
|
// actually report through. Without this, Start/Progress/Stopped never reach
|
||||||
|
// Jellyfin, so playback position never syncs and you can't resume on another
|
||||||
|
// device. The reporter shares the player controller's Arc, so populating it
|
||||||
|
// here lights up reporting on both desktop and Android, on every auth path
|
||||||
|
// that configures the player (login / restore / reauth).
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
let reporter_client = JellyfinClient::new(config)?;
|
||||||
|
let reporter = crate::playback_reporting::PlaybackReporter::new(
|
||||||
|
db_service,
|
||||||
|
Arc::new(TokioMutex::new(Some(reporter_client))),
|
||||||
|
user_id,
|
||||||
|
);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.set_jellyfin_client(Some(client));
|
controller.set_jellyfin_client(Some(client));
|
||||||
|
controller.set_playback_reporter(Some(reporter)).await;
|
||||||
|
|
||||||
log::info!("[PlayerCommand] Jellyfin client configured successfully");
|
log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1858,8 +1878,9 @@ pub async fn player_disable_jellyfin(
|
|||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.set_jellyfin_client(None);
|
controller.set_jellyfin_client(None);
|
||||||
|
controller.set_playback_reporter(None).await;
|
||||||
|
|
||||||
log::info!("[PlayerCommand] Jellyfin client disabled");
|
log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1018,9 +1018,13 @@ pub fn run() {
|
|||||||
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
|
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
|
||||||
app.manage(repository_manager_wrapper);
|
app.manage(repository_manager_wrapper);
|
||||||
|
|
||||||
// Initialize playback reporter wrapper (initially empty, set on login)
|
// Initialize playback reporter wrapper. This MUST share the same Arc
|
||||||
|
// the player controller and MPV progress loop report through (created
|
||||||
|
// above at `playback_reporter`), otherwise `playback_reporter_init`
|
||||||
|
// would populate a dead, parallel Option and no Start/Progress/Stopped
|
||||||
|
// would ever reach Jellyfin.
|
||||||
info!("[INIT] Initializing playback reporter wrapper...");
|
info!("[INIT] Initializing playback reporter wrapper...");
|
||||||
let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None)));
|
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
||||||
app.manage(playback_reporter_wrapper);
|
app.manage(playback_reporter_wrapper);
|
||||||
|
|
||||||
info!("[INIT] Application setup completed successfully");
|
info!("[INIT] Application setup completed successfully");
|
||||||
|
|||||||
@ -17,7 +17,8 @@ use super::backend::{PlayerBackend, PlayerError};
|
|||||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||||
use super::media::{MediaItem, MediaType};
|
use super::media::{MediaItem, MediaType};
|
||||||
use super::state::PlayerState;
|
use super::state::PlayerState;
|
||||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler};
|
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||||
|
use crate::utils::conversions::seconds_to_ticks;
|
||||||
|
|
||||||
/// Global reference to the JavaVM for JNI callbacks
|
/// Global reference to the JavaVM for JNI callbacks
|
||||||
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
|
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
|
||||||
@ -584,6 +585,75 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
} else {
|
} else {
|
||||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
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.
|
/// Called when player state changes.
|
||||||
|
|||||||
@ -165,9 +165,8 @@ impl PlayerController {
|
|||||||
self.jellyfin_client.clone()
|
self.jellyfin_client.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure the playback reporter for dual sync (local DB + server)
|
/// Configure the playback reporter for dual sync (local DB + server).
|
||||||
/// Will be called from initialization commands after login
|
/// Called from `player_configure_jellyfin` on login/restore/reauth.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
|
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
|
||||||
let mut reporter_guard = self.playback_reporter.lock().await;
|
let mut reporter_guard = self.playback_reporter.lock().await;
|
||||||
*reporter_guard = reporter;
|
*reporter_guard = reporter;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@ -152,7 +153,7 @@
|
|||||||
class="flex-1 min-w-0 text-left"
|
class="flex-1 min-w-0 text-left"
|
||||||
>
|
>
|
||||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{album.album_name}
|
{truncateMiddle(album.album_name, 40)}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-400 truncate">
|
<p class="text-xs text-gray-400 truncate">
|
||||||
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
@ -171,7 +172,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{album.name}
|
{truncateMiddle(album.name, 40)}
|
||||||
</p>
|
</p>
|
||||||
{#if album.productionYear}
|
{#if album.productionYear}
|
||||||
<p class="text-xs text-gray-400">{album.productionYear}</p>
|
<p class="text-xs text-gray-400">{album.productionYear}</p>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
@ -159,7 +160,7 @@
|
|||||||
|
|
||||||
<!-- Episode title -->
|
<!-- Episode title -->
|
||||||
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
||||||
{episode.name}
|
{truncateMiddle(episode.name, 64)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
@ -134,7 +135,7 @@
|
|||||||
{episodeNumber}.
|
{episodeNumber}.
|
||||||
</span>
|
</span>
|
||||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{episode.name}
|
{truncateMiddle(episode.name, 56)}
|
||||||
</h3>
|
</h3>
|
||||||
<!-- Played indicator -->
|
<!-- Played indicator -->
|
||||||
{#if episode.userData?.isPlayed}
|
{#if episode.userData?.isPlayed}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { navigateBack } from "$lib/utils/navigation";
|
import { navigateBack } from "$lib/utils/navigation";
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
import { currentLibrary } from "$lib/stores/library";
|
||||||
@ -229,7 +230,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{item.name}
|
{truncateMiddle(item.name, 40)}
|
||||||
</p>
|
</p>
|
||||||
{#if item.productionYear}
|
{#if item.productionYear}
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-400">
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
@ -106,7 +107,7 @@
|
|||||||
<!-- Title & Subtitle -->
|
<!-- Title & Subtitle -->
|
||||||
<div class="flex-1 min-w-0 text-left">
|
<div class="flex-1 min-w-0 text-left">
|
||||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{item.name}
|
{truncateMiddle(item.name, 56)}
|
||||||
</p>
|
</p>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
|
||||||
@ -166,7 +167,7 @@
|
|||||||
|
|
||||||
<div class="mt-2 space-y-0.5">
|
<div class="mt-2 space-y-0.5">
|
||||||
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{item.name}
|
{truncateMiddle(item.name, 40)}
|
||||||
</p>
|
</p>
|
||||||
{#if subtitle()}
|
{#if subtitle()}
|
||||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import type { PlayTracksContext } from "$lib/api/bindings";
|
import type { PlayTracksContext } from "$lib/api/bindings";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { queue } from "$lib/stores/queue";
|
import { queue } from "$lib/stores/queue";
|
||||||
@ -242,7 +243,7 @@
|
|||||||
<span
|
<span
|
||||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
||||||
>
|
>
|
||||||
{track.name}
|
{truncateMiddle(track.name, 48)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -356,7 +357,7 @@
|
|||||||
{#if currentlyPlayingId === track.id}
|
{#if currentlyPlayingId === track.id}
|
||||||
<span class="inline-block mr-1">▶</span>
|
<span class="inline-block mr-1">▶</span>
|
||||||
{/if}
|
{/if}
|
||||||
{track.name}
|
{truncateMiddle(track.name, 48)}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
|
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
|
||||||
{#if showArtist && showAlbum}
|
{#if showArtist && showAlbum}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
|
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
@ -242,7 +243,7 @@
|
|||||||
<div class="p-6 space-y-6 flex-shrink-0">
|
<div class="p-6 space-y-6 flex-shrink-0">
|
||||||
<!-- Title & Artist -->
|
<!-- Title & Artist -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h1 class="text-2xl font-bold text-white truncate">{displayMedia?.name}</h1>
|
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
|
||||||
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
|
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
|
||||||
{#if displayMedia?.artistItems?.length}
|
{#if displayMedia?.artistItems?.length}
|
||||||
{#each displayMedia?.artistItems as artist, i}
|
{#each displayMedia?.artistItems as artist, i}
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
@ -322,7 +323,7 @@
|
|||||||
<div
|
<div
|
||||||
class="text-sm font-medium text-white truncate block w-full text-left"
|
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||||
>
|
>
|
||||||
{displayMedia?.name}
|
{truncateMiddle(displayMedia?.name, 40)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||||
{#if displayMedia?.artistItems?.length}
|
{#if displayMedia?.artistItems?.length}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||||
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
|
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
@ -198,7 +199,7 @@
|
|||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
||||||
{item.name}
|
{truncateMiddle(item.name, 48)}
|
||||||
</p>
|
</p>
|
||||||
{#if item.artists?.length}
|
{#if item.artists?.length}
|
||||||
<p class="text-xs text-gray-400 truncate">
|
<p class="text-xs text-gray-400 truncate">
|
||||||
|
|||||||
147
src/lib/services/playerEvents.regression.test.ts
Normal file
147
src/lib/services/playerEvents.regression.test.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Player Events Service — regression tests
|
||||||
|
*
|
||||||
|
* These tests intentionally use the REAL player store (and its real derived
|
||||||
|
* stores), unlike playerEvents.test.ts which mocks the store away. The bugs
|
||||||
|
* covered here live in the interaction between the event handler and the
|
||||||
|
* store's position/duration fields, so a mocked store cannot catch them.
|
||||||
|
*
|
||||||
|
* TRACES: UR-005 | DR-001
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { get, writable } from "svelte/store";
|
||||||
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
import type { PlayerStatusEvent } from "$lib/api/bindings";
|
||||||
|
|
||||||
|
// Capture the handler that initPlayerEvents registers so we can drive events
|
||||||
|
// directly, exactly as the Tauri event bridge would.
|
||||||
|
let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null;
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn(async (_event: string, handler: any) => {
|
||||||
|
registeredHandler = handler;
|
||||||
|
return () => {};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The current queue item is what handleStateChanged seeds playing/paused state
|
||||||
|
// from. Back it with a writable so each test can install its own item.
|
||||||
|
const currentQueueItemStore = writable<MediaItem | null>(null);
|
||||||
|
vi.mock("$lib/stores/queue", () => ({
|
||||||
|
queue: { subscribe: vi.fn() },
|
||||||
|
currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep the player in local mode so events aren't skipped as remote.
|
||||||
|
// playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts
|
||||||
|
// imports `isRemoteMode` from the same module — provide both.
|
||||||
|
vi.mock("$lib/stores/playbackMode", () => ({
|
||||||
|
playbackMode: {
|
||||||
|
setMode: vi.fn(),
|
||||||
|
initializeSessionMonitoring: vi.fn(),
|
||||||
|
subscribe: (run: any) => {
|
||||||
|
run({ mode: "local", isTransferring: false });
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Remote-mode merged stores in player.ts read this; stub as no remote session.
|
||||||
|
vi.mock("$lib/stores/sessions", () => ({
|
||||||
|
selectedSession: { subscribe: (run: any) => (run(null), () => {}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/sleepTimer", () => ({
|
||||||
|
sleepTimer: { set: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/nextEpisode", () => ({
|
||||||
|
nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/services/preload", () => ({
|
||||||
|
preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||||
|
return {
|
||||||
|
id: "track-1",
|
||||||
|
name: "Test Track",
|
||||||
|
type: "Audio",
|
||||||
|
runTimeTicks: null,
|
||||||
|
...overrides,
|
||||||
|
} as MediaItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fire(event: PlayerStatusEvent): Promise<void> {
|
||||||
|
if (!registeredHandler) throw new Error("handler not registered");
|
||||||
|
await registeredHandler({ payload: event });
|
||||||
|
// Let any async work inside the handler settle.
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Player Events — pause must not zero the slider duration", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// initPlayerEvents is a singleton; reset it so each test re-registers its
|
||||||
|
// own handler and starts from idle player state.
|
||||||
|
const { cleanupPlayerEvents } = await import("./playerEvents");
|
||||||
|
const { player } = await import("$lib/stores/player");
|
||||||
|
cleanupPlayerEvents();
|
||||||
|
player.setIdle();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
registeredHandler = null;
|
||||||
|
currentQueueItemStore.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the live duration across pause when runTimeTicks is missing", async () => {
|
||||||
|
// runTimeTicks is null — the previous code recomputed duration as 0 here,
|
||||||
|
// which collapsed the slider's max and snapped the thumb to the start.
|
||||||
|
const item = makeItem({ runTimeTicks: null });
|
||||||
|
currentQueueItemStore.set(item);
|
||||||
|
|
||||||
|
const { initPlayerEvents } = await import("./playerEvents");
|
||||||
|
const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player");
|
||||||
|
|
||||||
|
await initPlayerEvents();
|
||||||
|
|
||||||
|
// 1. Track starts playing.
|
||||||
|
await fire({ type: "state_changed", state: "playing", media_id: item.id });
|
||||||
|
// 2. Backend reports the real duration once media is loaded / position ticks.
|
||||||
|
await fire({ type: "position_update", position: 42, duration: 180 });
|
||||||
|
|
||||||
|
expect(get(playbackPosition)).toBe(42);
|
||||||
|
expect(get(playbackDuration)).toBe(180);
|
||||||
|
|
||||||
|
// 3. User pauses. Position AND duration must survive — duration is what
|
||||||
|
// drives the slider's max, so a 0 here is what caused the regression.
|
||||||
|
await fire({ type: "state_changed", state: "paused", media_id: item.id });
|
||||||
|
|
||||||
|
expect(get(playbackPosition)).toBe(42);
|
||||||
|
expect(get(playbackDuration)).toBe(180);
|
||||||
|
|
||||||
|
// Sanity: the store is genuinely paused, not reset to idle/loading.
|
||||||
|
expect(get(player).state.kind).toBe("paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
|
||||||
|
// 70s in ticks (1 tick = 100ns) → 700_000_000.
|
||||||
|
const item = makeItem({ runTimeTicks: 700_000_000 });
|
||||||
|
currentQueueItemStore.set(item);
|
||||||
|
|
||||||
|
const { initPlayerEvents } = await import("./playerEvents");
|
||||||
|
const { playbackDuration } = await import("$lib/stores/player");
|
||||||
|
|
||||||
|
await initPlayerEvents();
|
||||||
|
|
||||||
|
// No position_update yet, so there is no live duration to prefer.
|
||||||
|
await fire({ type: "state_changed", state: "paused", media_id: item.id });
|
||||||
|
|
||||||
|
expect(get(playbackDuration)).toBe(70);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import { type UnlistenFn } from "@tauri-apps/api/event";
|
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
|
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
|
||||||
import { player, playbackPosition, currentMedia } from "$lib/stores/player";
|
import { player, playbackPosition, playbackDuration, currentMedia } from "$lib/stores/player";
|
||||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||||
import { playbackMode } from "$lib/stores/playbackMode";
|
import { playbackMode } from "$lib/stores/playbackMode";
|
||||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||||
@ -142,6 +142,26 @@ function handlePositionUpdate(position: number, duration: number): void {
|
|||||||
// Note: Sleep timer logic is now handled entirely in the Rust backend
|
// Note: Sleep timer logic is now handled entirely in the Rust backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the duration to seed playing/paused state with.
|
||||||
|
*
|
||||||
|
* For the same track that is already loaded, the live store duration (kept
|
||||||
|
* fresh by media_loaded / position_update events) is authoritative and is
|
||||||
|
* preferred — falling back to the runTimeTicks estimate only when the store
|
||||||
|
* has no usable duration yet. This avoids clobbering a known-good duration
|
||||||
|
* with 0 when runTimeTicks is missing (which would zero the slider's max).
|
||||||
|
*/
|
||||||
|
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
|
||||||
|
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||||
|
if (isSameTrack) {
|
||||||
|
const live = get(playbackDuration);
|
||||||
|
if (live > 0) {
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return estimate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle state change events.
|
* Handle state change events.
|
||||||
*
|
*
|
||||||
@ -171,7 +191,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
const previous = get(currentMedia);
|
const previous = get(currentMedia);
|
||||||
const isSameTrack = previous?.id === currentItem.id;
|
const isSameTrack = previous?.id === currentItem.id;
|
||||||
const startPosition = isSameTrack ? get(playbackPosition) : 0;
|
const startPosition = isSameTrack ? get(playbackPosition) : 0;
|
||||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
const initialDuration = resolveDuration(currentItem, isSameTrack);
|
||||||
player.setPlaying(currentItem, startPosition, initialDuration);
|
player.setPlaying(currentItem, startPosition, initialDuration);
|
||||||
|
|
||||||
// Trigger preloading of upcoming tracks in the background
|
// Trigger preloading of upcoming tracks in the background
|
||||||
@ -180,9 +200,15 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
console.debug("[playerEvents] Preload failed (non-critical):", e);
|
console.debug("[playerEvents] Preload failed (non-critical):", e);
|
||||||
});
|
});
|
||||||
} else if (state === "paused" && currentItem) {
|
} else if (state === "paused" && currentItem) {
|
||||||
// Keep current position from store
|
// Keep current position and duration from store. The same track is
|
||||||
|
// already loaded on pause, so its live duration (from media_loaded /
|
||||||
|
// position_update) is authoritative — recomputing from runTimeTicks
|
||||||
|
// would clobber it with 0 when runTimeTicks is missing, forcing the
|
||||||
|
// slider's max to 0 and flashing the thumb to the start.
|
||||||
|
const previous = get(currentMedia);
|
||||||
|
const isSameTrack = previous?.id === currentItem.id;
|
||||||
const currentPosition = get(playbackPosition);
|
const currentPosition = get(playbackPosition);
|
||||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
const initialDuration = resolveDuration(currentItem, isSameTrack);
|
||||||
player.setPaused(currentItem, currentPosition, initialDuration);
|
player.setPaused(currentItem, currentPosition, initialDuration);
|
||||||
} else if (state === "loading" && currentItem) {
|
} else if (state === "loading" && currentItem) {
|
||||||
player.setLoading(currentItem);
|
player.setLoading(currentItem);
|
||||||
|
|||||||
@ -250,6 +250,29 @@ export const mergedVolume = derived(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an item is video content and therefore must NOT appear in the audio
|
||||||
|
* mini player. Checks the Jellyfin item type (Movie/Episode/live TV) AND the
|
||||||
|
* backend queue item's `mediaType` discriminator.
|
||||||
|
*
|
||||||
|
* The `mediaType` check is essential: a video started via `player_play_item`
|
||||||
|
* (every Movie/Episode goes through VideoPlayer) is pushed onto the backend
|
||||||
|
* queue as a PlayerMediaItem with NO `type` field but `mediaType: "video"`.
|
||||||
|
* Without this, that queue item's absent `type` slips past the Movie/Episode
|
||||||
|
* check and the video surfaces in the audio mini player after leaving /player.
|
||||||
|
*/
|
||||||
|
function isVideoItem(item: MediaItem | null): boolean {
|
||||||
|
if (!item) return false;
|
||||||
|
const type = item.type;
|
||||||
|
if (type === "Movie" || type === "Episode" || type === "TvChannel") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Backend PlayerMediaItem carries a lowercase mediaType discriminator that is
|
||||||
|
// present even when the Jellyfin `type` is absent (video-only play requests).
|
||||||
|
const mediaType = (item as { mediaType?: string }).mediaType;
|
||||||
|
return mediaType === "video";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should show audio miniplayer - state machine gated
|
* Should show audio miniplayer - state machine gated
|
||||||
* Only true when:
|
* Only true when:
|
||||||
@ -268,8 +291,8 @@ export const shouldShowAudioMiniPlayer = derived(
|
|||||||
|
|
||||||
// Determine media type from the player state, falling back to the queue
|
// Determine media type from the player state, falling back to the queue
|
||||||
// item (the player store can momentarily lack media during transitions).
|
// item (the player store can momentarily lack media during transitions).
|
||||||
const mediaType = $media?.type ?? $queueItem?.type;
|
const item = $media ?? $queueItem;
|
||||||
if (mediaType === "Movie" || mediaType === "Episode") {
|
if (isVideoItem(item)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,25 @@ describe("shouldShowAudioMiniPlayer", () => {
|
|||||||
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides a video queue item that has no Jellyfin `type` but mediaType 'video'", () => {
|
||||||
|
// Regression: player_play_item (every Movie/Episode via VideoPlayer) pushes
|
||||||
|
// a backend PlayerMediaItem onto the queue with item_type: None but
|
||||||
|
// media_type: Video → serialized with no `type` and mediaType: "video".
|
||||||
|
// Without the mediaType check this slipped past the Movie/Episode guard and
|
||||||
|
// surfaced the last-played video in the audio mini player after leaving
|
||||||
|
// /player.
|
||||||
|
const videoQueueItem = { id: "v2", mediaType: "video" } as unknown as MediaItem;
|
||||||
|
currentQueueItem.set(videoQueueItem);
|
||||||
|
player.setIdle();
|
||||||
|
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides for a live TV channel", () => {
|
||||||
|
const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem;
|
||||||
|
player.setPlaying(channelItem, 0, 0);
|
||||||
|
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows in remote mode when the session has a now-playing item", () => {
|
it("shows in remote mode when the session has a now-playing item", () => {
|
||||||
isRemoteMode.set(true);
|
isRemoteMode.set(true);
|
||||||
selectedSession.set({ nowPlayingItem: { id: "r1" } });
|
selectedSession.set({ nowPlayingItem: { id: "r1" } });
|
||||||
|
|||||||
39
src/lib/utils/truncateMiddle.test.ts
Normal file
39
src/lib/utils/truncateMiddle.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { truncateMiddle } from "./truncateMiddle";
|
||||||
|
|
||||||
|
describe("truncateMiddle", () => {
|
||||||
|
it("returns short strings unchanged", () => {
|
||||||
|
expect(truncateMiddle("short", 32)).toBe("short");
|
||||||
|
expect(truncateMiddle("exactly-len", 11)).toBe("exactly-len");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abbreviates in the middle keeping head and tail", () => {
|
||||||
|
const result = truncateMiddle("long_media_name_like_this", 16);
|
||||||
|
expect(result).toContain("…");
|
||||||
|
expect(result.length).toBe(16);
|
||||||
|
expect(result.startsWith("long")).toBe(true);
|
||||||
|
expect(result.endsWith("this")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never exceeds maxLength", () => {
|
||||||
|
for (const len of [1, 2, 3, 5, 10, 25]) {
|
||||||
|
expect(truncateMiddle("a".repeat(100), len).length).toBeLessThanOrEqual(len);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null and undefined", () => {
|
||||||
|
expect(truncateMiddle(null)).toBe("");
|
||||||
|
expect(truncateMiddle(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to head-truncation when there is no room for content", () => {
|
||||||
|
expect(truncateMiddle("abcdef", 1)).toBe("a");
|
||||||
|
expect(truncateMiddle("abcdef", 0)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports a custom ellipsis", () => {
|
||||||
|
const result = truncateMiddle("long_media_name_like_this", 16, "...");
|
||||||
|
expect(result).toContain("...");
|
||||||
|
expect(result.length).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
src/lib/utils/truncateMiddle.ts
Normal file
33
src/lib/utils/truncateMiddle.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Abbreviate a long string in the middle so both the start and the end stay
|
||||||
|
* visible, e.g. `long_media_name_like_this` -> `long_med…ike_this`.
|
||||||
|
*
|
||||||
|
* Unlike CSS `text-overflow: ellipsis` (which only clips the end), this keeps
|
||||||
|
* the tail of a media name — often the most distinguishing part (episode
|
||||||
|
* number, disambiguating suffix, etc.).
|
||||||
|
*
|
||||||
|
* @param value The string to abbreviate.
|
||||||
|
* @param maxLength Maximum length of the returned string, including the ellipsis.
|
||||||
|
* @param ellipsis The separator inserted in the middle (default `…`).
|
||||||
|
*/
|
||||||
|
export function truncateMiddle(
|
||||||
|
value: string | null | undefined,
|
||||||
|
maxLength = 32,
|
||||||
|
ellipsis = "…",
|
||||||
|
): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (maxLength <= 0) return "";
|
||||||
|
if (value.length <= maxLength) return value;
|
||||||
|
|
||||||
|
// Not enough room for any real content around the ellipsis: fall back to a
|
||||||
|
// plain head-truncation so we never return more than maxLength characters.
|
||||||
|
if (maxLength <= ellipsis.length) {
|
||||||
|
return value.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keep = maxLength - ellipsis.length;
|
||||||
|
const head = Math.ceil(keep / 2);
|
||||||
|
const tail = Math.floor(keep / 2);
|
||||||
|
|
||||||
|
return value.slice(0, head) + ellipsis + (tail > 0 ? value.slice(value.length - tail) : "");
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user