Asked for directly. Checked the real server before building anything: no plugin provides this (Cinema Intros is a different feature - pre-show trailers, not per-episode skip points), and Emby's own Chapters field turned out to be auto-generated placeholders every 5 minutes, not real markers. What the server does have natively, confirmed from its own /openapi.json rather than assumed: a per-library EnableMarkerDetection option backed by a built-in "Detect Episode Intros" scheduled task - audio-fingerprint detection, the same idea as Jellyfin's separate Intro Skipper plugin, already part of Emby itself. Enabling it (done directly on the server for the two TV libraries) adds real entries to the same Chapters array, distinguished by MarkerType: IntroStart/IntroEnd/ CreditsStart. This made the feature almost entirely "read what Emby already computed" - no fingerprinting, no new persistence, no background jobs of this app's own. CatalogService.getPlaybackMarkers fetches Fields=Chapters and picks out just those three marker types (nulls when detection hasn't reached this item or isn't enabled for its library - never an error), behind a new GET /api/catalog/item/:id/markers route mirroring getPlaybackOptions. The player gets one new floating button, #skipIntroBtn - an explicit label, not an icon, matching this app's own convention for every request-shaped control. It's a sibling of #bottomBar, not inside it, so it stays reachable through the bar's own auto-hide/.minimal collapse. Clicking it seeks to introEndTicks through the exact same activeDriver/onDriverStateChanged path any other seek uses, so it's automatically host-only and automatically cast-aware with no new special-casing for either. End credits got no button of their own - #nextBtn already does exactly "go to the next queued item" and was already shown whenever hasNextQueued is true; the only gap closed here is visibility, by un-minimizing the bar once (a one-shot flag, not a fight against someone deliberately re-minimizing it) the moment playback crosses creditsStartTicks. Verified: full typecheck/test/build on both projects (125/125 server tests, including new coverage for getPlaybackMarkers - real MarkerType values mixed with auto-generated Chapter ones, and both "no Chapters" and "no markers among the Chapters" resolving to nulls rather than throwing). The button's on-screen position was checked directly (getBoundingClientRect against the real compiled CSS) at two viewport sizes - clear of #bottomBar with a 10px gap, not overlapping. Not verified: the detection task had only just been enabled on the real server and hadn't finished analyzing any episode yet, so the actual end-to-end "a detected marker makes the button appear at the right second" behavior needs a real check once it's had time to run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
7.2 KiB
HTML
134 lines
7.2 KiB
HTML
<div class="playerWrap">
|
|
<!-- disableremoteplayback: without it, Chrome adds its OWN native cast
|
|
icon to this element's native controls (see becomeHost in
|
|
Player.ts) the moment it detects a Cast-capable device on the
|
|
network - entirely separate from, and visually duplicating,
|
|
#castBtn's own custom integration (cast.ts). Standard attribute
|
|
for exactly this - see docs/architecture.md. -->
|
|
<video id="video" playsinline disableremoteplayback></video>
|
|
|
|
<!-- Replaces the video content while casting - the local <video> is
|
|
paused/torn down (see Player.ts's startCasting/cast.ts), so
|
|
leaving its last frame frozen with no explanation would just look
|
|
stuck. See docs/architecture.md's "Casting to a Chromecast". -->
|
|
<div id="castingScreen" class="castingScreen hide">
|
|
<svg viewBox="0 0 24 24" width="40" height="40" aria-hidden="true">
|
|
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"></rect>
|
|
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"></path>
|
|
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"></path>
|
|
<circle cx="4" cy="20" r="1.3" fill="currentColor"></circle>
|
|
</svg>
|
|
<strong>Diffusion sur le Chromecast</strong>
|
|
</div>
|
|
|
|
<p id="loadingNotice" class="loadingNotice"><span class="spinner"></span>Chargement du flux...</p>
|
|
<div id="playerError" class="playerError hide">
|
|
<p id="playerErrorText"></p>
|
|
<button id="playerErrorRetry" class="linklike">Réessayer</button>
|
|
</div>
|
|
|
|
<div class="topOverlay">
|
|
<div class="actionBar">
|
|
<span id="itemTitle" class="itemTitle"></span>
|
|
<span id="members" class="members"></span>
|
|
|
|
<button id="castBtn" class="iconBtn hide" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
|
|
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
|
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
|
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
|
|
</svg>
|
|
</button>
|
|
|
|
<div id="settingsWrap" class="settingsWrap hide">
|
|
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</button>
|
|
<div id="settingsPanel" class="settingsPanel hide">
|
|
<label>Langue <select id="audioSelect"></select></label>
|
|
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
|
<label>Qualité <select id="qualitySelect"></select></label>
|
|
<p id="burnInHint" class="hint burnInHint hide">Flux incrusté actif pour toute la party - langue et qualité fixées tant qu'il l'est.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button id="leaveBtn" class="linklike">Quitter</button>
|
|
</div>
|
|
|
|
<div id="presenceOverlay" class="presenceOverlay"></div>
|
|
</div>
|
|
|
|
<!-- Transient toast (see showRequestBanner in Player.ts) - positioned
|
|
independently of the bottom bar below, near the top, so it never
|
|
fights that persistent bar for space and reads as a pop-in/fade-out
|
|
notification rather than a fourth thing bolted onto a fixed layout. -->
|
|
<div id="requestBanner" class="requestBanner hide"></div>
|
|
|
|
<!-- Emby's own native intro detection (see Player.ts's updateSkipButton/
|
|
CatalogService.getPlaybackMarkers) - shown independently of #bottomBar
|
|
below, and NOT subject to its own auto-hide/.minimal collapse, since
|
|
this needs to stay reachable the moment the intro starts even if
|
|
nobody's touched the player yet. Host-only, like every other seek -
|
|
see updateSkipButton's own comment for why a follower can't get this
|
|
button at all. Explicit label, not just an icon - see
|
|
docs/architecture.md's player-rework section for why this app
|
|
standardized on that for every request-shaped control. -->
|
|
<button id="skipIntroBtn" class="skipMarkerBtn hide">Passer l'intro</button>
|
|
|
|
<!-- One bar, bottom-anchored, replacing what used to be three separate
|
|
floating pieces (a centered banner/notice column, a request
|
|
toolbar cornered bottom-right, an "Épisode suivant" link up in the
|
|
top bar) AND the browser's own native <video controls> (see
|
|
becomeHost in Player.ts - never enabled any more, precisely
|
|
because that native bar used to render as its own always-on-top
|
|
layer fighting this one for the same space, especially visible
|
|
once plein écran started showing this bar too - see
|
|
docs/architecture.md). A seek bar on top, buttons below - staying
|
|
INSIDE .playerWrap, not a sibling positioned relative to it, is
|
|
what keeps all of this visible and clickable once plein écran
|
|
targets the whole wrap instead of just the <video>. -->
|
|
<div id="bottomBar" class="bottomBar">
|
|
<!-- Host: click/drag to seek (see Player.ts's pointerdown/move/up
|
|
handlers). Follower: purely a read-only position indicator,
|
|
ticked from activeDriver just like the fill. -->
|
|
<div id="progressBar" class="progressBar">
|
|
<div id="progressFill" class="progressFill"></div>
|
|
</div>
|
|
|
|
<div class="bottomBarButtons">
|
|
<div class="bottomBarZone left">
|
|
<button id="playPauseBtn" class="iconBtn hide" title="Lecture / pause" aria-label="Lecture / pause"></button>
|
|
<button id="muteBtn" class="iconBtn" title="Muet" aria-label="Muet"></button>
|
|
<span id="timeLabel" class="timeLabel"></span>
|
|
<button id="nextBtn" class="nextBtn hide">
|
|
<svg width="12" height="10" viewBox="0 0 14 10" aria-hidden="true">
|
|
<path d="M1 1 L6 5 L1 9 Z" fill="#fff"></path>
|
|
<path d="M7 1 L12 5 L7 9 Z" fill="#fff"></path>
|
|
<rect x="12.4" y="0.5" width="1.6" height="9" fill="#fff"></rect>
|
|
</svg>
|
|
Épisode suivant
|
|
</button>
|
|
<div id="requestToolbar" class="requestToolbar hide">
|
|
<button id="requestPauseBtn" class="requestBtn">
|
|
<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true">
|
|
<rect x="1.5" y="1" width="2.4" height="8" fill="#fff"></rect>
|
|
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
|
|
</svg>
|
|
Demander pause
|
|
</button>
|
|
<button id="requestResumeBtn" class="requestBtn">
|
|
<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true">
|
|
<path d="M1.5 1 L8.5 5 L1.5 9 Z" fill="#fff"></path>
|
|
</svg>
|
|
Demander reprise
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="bottomBarZone center">
|
|
<span id="ambientNotice" class="ambientNotice hide"></span>
|
|
</div>
|
|
<div class="bottomBarZone right">
|
|
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|