Jellyfin 10.9+ includes a native Media Segments API (/MediaSegments/{itemId}) that marks
skippable regions — Intro, Outro, Recap, Preview, Commercial — for any item (movies and
episodes alike). WiiFin currently supports only the community Intro Skipper plugin
(/Episode/{id}/IntroTimestamps), which only covers intros on episodes and requires a
separate plugin.
Goal: call the native API first; fall back to the plugin endpoint for servers that don't have Jellyfin 10.9+ or don't have segments populated; show a per-segment skip prompt with type-aware wording; let the user press A to jump past any active segment.
| File | Change |
|---|---|
source/jellyfin/JellyfinClient.h |
Add MediaSegment struct + getMediaSegments() declaration |
source/jellyfin/JellyfinClient.cpp |
Implement getMediaSegments() |
source/player/PlayerOverlay.h |
Add segments to context; add activeSegmentIdx + updateSegmentPrompt() to class |
source/player/PlayerOverlay.cpp |
Implement updateSegmentPrompt(); update A-press skip handler |
source/core/App.cpp |
Call getMediaSegments() for all items; keep getIntroTimestamps() as episode-only fallback |
After the existing IntroInfo struct (line 89), add:
/* Native Jellyfin 10.9+ Media Segments API */
struct MediaSegment {
float startSecs = 0.0f; /* start of skippable region (seconds) */
float endSecs = 0.0f; /* end of skippable region (seconds) */
std::string type; /* "Intro", "Outro", "Recap", "Preview", "Commercial", … */
};After getIntroTimestamps() declaration (line 284), add:
// Fetch skippable segments for any item (Jellyfin 10.9+).
// GET /MediaSegments/{itemId}
// Returns true even when empty (404 or no segments = not an error).
bool getMediaSegments(const std::string& serverUrl,
const JellyfinAuth& auth,
const std::string& itemId,
std::vector<MediaSegment>& out);Add after getIntroTimestamps() (after line 2223):
// ---------------------------------------------------------------------------
// Native Media Segments API — Jellyfin 10.9+
// GET /MediaSegments/{itemId}
// Response: {"Items":[{"StartTicks":…,"EndTicks":…,"Type":"Intro"},…]}
// ---------------------------------------------------------------------------
bool JellyfinClient::getMediaSegments(const std::string& serverUrl,
const JellyfinAuth& auth,
const std::string& itemId,
std::vector<MediaSegment>& out)
{
out.clear();
std::string url = serverUrl + "/MediaSegments/" + itemId;
std::string resp;
int status = httpRequest(url, "GET", "", "", auth.accessToken, resp);
if (status != 200) return true; /* not available / no segments: silent */
/* Walk the "Items" array without a full JSON parser.
* Find each {...} object in the array and extract the three fields. */
size_t arrPos = resp.find("\"Items\"");
if (arrPos == std::string::npos) return true;
arrPos = resp.find('[', arrPos);
if (arrPos == std::string::npos) return true;
size_t pos = arrPos + 1;
while (pos < resp.size()) {
/* skip whitespace / commas between objects */
while (pos < resp.size() &&
(resp[pos] == ' ' || resp[pos] == '\n' ||
resp[pos] == '\r' || resp[pos] == '\t' ||
resp[pos] == ',')) ++pos;
if (pos >= resp.size() || resp[pos] == ']') break;
if (resp[pos] != '{') break;
/* find matching closing brace */
size_t objStart = pos;
int depth = 0;
while (pos < resp.size()) {
if (resp[pos] == '{') ++depth;
else if (resp[pos] == '}') { if (--depth == 0) { ++pos; break; } }
++pos;
}
std::string obj = resp.substr(objStart, pos - objStart);
long long startTicks = jsonGetLongLong(obj, "StartTicks");
long long endTicks = jsonGetLongLong(obj, "EndTicks");
std::string type = jsonGetString(obj, "Type");
if (endTicks > startTicks && !type.empty()) {
MediaSegment seg;
seg.startSecs = (float)(startTicks / 10000000LL);
seg.endSecs = (float)(endTicks / 10000000LL);
seg.type = type;
out.push_back(seg);
}
}
return true;
}In PlayerOverlayContext (after IntroInfo intro;):
/* Native Media Segments (Jellyfin 10.9+) — preferred over IntroInfo */
std::vector<MediaSegment> segments;In the PlayerOverlay private section, replace:
bool introPromptShown = false;with:
bool introPromptShown = false; /* fallback: intro-skipper plugin path */
int activeSegmentIdx = -1; /* index into ctx.segments, or -1 */Add updateSegmentPrompt() alongside updateIntroPrompt():
void updateSegmentPrompt(float timePos);Add helper before updateIntroPrompt():
static const char* segSkipPrompt(const std::string& t) {
if (t == "Intro") return "Press A to skip intro";
if (t == "Outro") return "Press A to skip credits";
if (t == "Recap") return "Press A to skip recap";
if (t == "Preview") return "Press A to skip preview";
if (t == "Commercial") return "Press A to skip ad";
return "Press A to skip";
}
static const char* segSkippedMsg(const std::string& t) {
if (t == "Intro") return "Skipped intro";
if (t == "Outro") return "Skipped credits";
if (t == "Recap") return "Skipped recap";
if (t == "Preview") return "Skipped preview";
if (t == "Commercial") return "Skipped ad";
return "Skipped";
}Add updateSegmentPrompt() after updateIntroPrompt():
void PlayerOverlay::updateSegmentPrompt(float timePos)
{
if (ctx.segments.empty()) return;
int newIdx = -1;
for (int i = 0; i < (int)ctx.segments.size(); ++i) {
const MediaSegment& s = ctx.segments[i];
if (timePos >= s.startSecs && timePos < s.endSecs) {
newIdx = i;
break;
}
}
if (newIdx != activeSegmentIdx) {
activeSegmentIdx = newIdx;
if (newIdx >= 0)
showOSD(segSkipPrompt(ctx.segments[newIdx].type), 6000);
}
}In tick(), call updateSegmentPrompt in addition to updateIntroPrompt:
updateIntroPrompt(timePos);
updateSegmentPrompt(timePos);Update the A-press skip handler (currently around line 518): Replace the existing block:
if (introPromptShown && ctx.intro.hasIntro && !g_mplayer_paused) {
wii_player_seek_abs(ctx.intro.introEnd + 0.5f);
introPromptShown = false;
showOSD("Skipped intro", 2000);
}with:
if (!g_mplayer_paused) {
if (activeSegmentIdx >= 0 && activeSegmentIdx < (int)ctx.segments.size()) {
/* Native segment skip — preferred */
const MediaSegment& seg = ctx.segments[activeSegmentIdx];
wii_player_seek_abs(seg.endSecs + 0.5f);
showOSD(segSkippedMsg(seg.type), 2000);
activeSegmentIdx = -1;
} else if (introPromptShown && ctx.intro.hasIntro) {
/* Fallback: intro-skipper plugin */
wii_player_seek_abs(ctx.intro.introEnd + 0.5f);
introPromptShown = false;
showOSD("Skipped intro", 2000);
} else if (!videoMode) {
wii_player_pause_toggle();
}
}Replace:
/* Try to fetch intro timestamps (for TV episodes) */
if (!episodes.empty()) {
SYS_Report("[runPlay] getIntroTimestamps start\n");
client.getIntroTimestamps(serverUrl, auth, itemId, ctx.intro);
SYS_Report("[runPlay] getIntroTimestamps done\n");
}with:
/* Try native Media Segments API first (Jellyfin 10.9+, works for all items) */
SYS_Report("[runPlay] getMediaSegments start\n");
client.getMediaSegments(serverUrl, auth, itemId, ctx.segments);
SYS_Report("[runPlay] getMediaSegments done (%d segments)\n", (int)ctx.segments.size());
/* Fall back to Intro Skipper plugin for episodes on older servers */
if (ctx.segments.empty() && !episodes.empty()) {
SYS_Report("[runPlay] getIntroTimestamps start\n");
client.getIntroTimestamps(serverUrl, auth, itemId, ctx.intro);
SYS_Report("[runPlay] getIntroTimestamps done\n");
}- Build:
makein the repo root — zero new warnings expected. - Jellyfin 10.9+ server: Play an episode that has media segments set; verify "Press A to skip intro" (or "recap"/"credits") appears at the right time and that pressing A seeks past the segment.
- Multiple segments: Play content with both Intro and Outro segments; verify each prompts independently when their time range is active.
- Fallback path: On a server without Media Segments or segments populated, the
ctx.segmentsvector will be empty andgetIntroTimestamps()will run as before; existing intro-skip behaviour is preserved. - Movie support: Media Segments also works on movies (not just episodes); verify a movie with a segment shows the prompt.
- No segments: Play content with no segments — no prompt should appear; normal A-button behaviour (pause/play) unchanged.