;
+ let translate: TranslateService;
+
+ const slider = () =>
+ fixture.nativeElement.querySelector(
+ '.player-controls__slider'
+ ) as HTMLInputElement;
+
+ const valueText = () => slider().getAttribute('aria-valuetext');
+
+ /** A keyboard step: the range input moves, then commits. */
+ const step = (value: number) => {
+ const element = slider();
+ element.value = String(value);
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ fixture.detectChanges();
+ };
+
+ beforeEach(async () => {
+ localStorage.removeItem('volume');
+ await TestBed.configureTestingModule({
+ imports: [PlayerControlsComponent, TranslateModule.forRoot()],
+ }).compileComponents();
+ translate = TestBed.inject(TranslateService);
+ translate.setTranslation('en', {
+ EMBEDDED_MPV: {
+ PLAYER: { TIMELINE_SEGMENT_POSITION: '{{title}} · {{time}}' },
+ },
+ });
+ translate.use('en');
+
+ fake = createFakeController();
+ fixture = TestBed.createComponent(PlayerControlsComponent);
+ fixture.componentRef.setInput('controller', fake.controller);
+ fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, seek: true });
+ fake.state.set({
+ ...createEmptyControlsState(),
+ canSeek: true,
+ durationSeconds: 3600,
+ positionSeconds: 300,
+ });
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ });
+
+ it('reads the plain time without segments', () => {
+ fixture.detectChanges();
+
+ expect(valueText()).toBe('5:00');
+ });
+
+ it('names the catch-up programme at the position and at the keyboard target', () => {
+ const programme = (title: string, start: number, stop: number) => ({
+ title,
+ start: '',
+ stop: '',
+ startTimestamp: start,
+ stopTimestamp: stop,
+ });
+ const active = programme('Morning Report', 50_000, 51_800);
+ fixture.componentRef.setInput(
+ 'timelineSegments',
+ buildCatchupTimelineSegments(
+ [active, programme('Weather', 51_800, 53_600)],
+ active,
+ 53_600
+ )
+ );
+ fixture.detectChanges();
+
+ expect(valueText()).toBe('Morning Report · 5:00');
+
+ step(1801);
+ expect(valueText()).toBe('Weather · 30:01');
+ });
+
+ it('names file chapters and reads plain time in an untitled one', () => {
+ // Chapters as Embedded MPV reports them: each runs to the next.
+ fixture.componentRef.setInput('timelineSegments', [
+ { startSeconds: 0, endSeconds: 240, title: 'Opening' },
+ { startSeconds: 240, endSeconds: 3300, title: null },
+ { startSeconds: 3300, endSeconds: 3600, title: 'Credits' },
+ ]);
+ fixture.detectChanges();
+
+ expect(valueText()).toBe('5:00');
+
+ step(120);
+ expect(valueText()).toBe('Opening · 2:00');
+
+ step(3600);
+ expect(valueText()).toBe('Credits · 1:00:00');
+ });
+
+ it('follows the locale template', () => {
+ translate.setTranslation('de', {
+ EMBEDDED_MPV: {
+ PLAYER: { TIMELINE_SEGMENT_POSITION: '{{time}} – {{title}}' },
+ },
+ });
+ fixture.componentRef.setInput('timelineSegments', [
+ { startSeconds: 0, endSeconds: 600, title: 'Kapitel 1' },
+ ]);
+ fixture.detectChanges();
+
+ translate.use('de');
+ fixture.detectChanges();
+
+ expect(valueText()).toBe('5:00 – Kapitel 1');
+ });
+});
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts
index d5552c155..299296235 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts
@@ -25,7 +25,7 @@ import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsStreamStats } from './controls-stream-stats';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
-import { ControlsTimelineHover } from './controls-timeline-hover';
+import { ControlsTimelineLabel } from './controls-timeline-label';
import { ControlsUpNext } from './controls-up-next';
import { ControlsVisibility } from './controls-visibility';
import { createControlsViewModel } from './controls-view-model';
@@ -165,12 +165,14 @@ export class PlayerControlsComponent implements OnDestroy {
private readonly controllerVolume = computed(() => this.state().volume);
readonly timeline = new ControlsTimeline(this.state, this.timelineSegments);
readonly scrubPosition = this.timeline.scrubPosition;
- readonly timelineHover = new ControlsTimelineHover({
+ readonly timelineLabel = new ControlsTimelineLabel({
duration: this.timeline.duration,
interactive: computed(
() => this.capabilities().seek && this.state().canSeek
),
segments: this.timeline.segments,
+ value: this.timeline.value,
+ scrubbing: computed(() => this.scrubPosition() !== null),
});
/** `−7:03` while a finite duration is known; the dock prefers it to the total. */
readonly remainingTimeText = computed(() =>
diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts
index ac40ad085..148eacdd7 100644
--- a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts
+++ b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts
@@ -1,5 +1,6 @@
import { readdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
+import { TIMELINE_SEGMENT_GAP_PX } from './controls-timeline-segments';
// The overlay sits on video in both app themes, so its colours come from the
// fixed `--pc-*` palette. A theme token read here stays invisible only while
@@ -32,6 +33,7 @@ const TIMELINE_STYLES =
STYLE_SOURCES.get('player-timeline.component.scss') ?? '';
const SETTINGS_STYLES =
STYLE_SOURCES.get('player-settings-panel.component.scss') ?? '';
+const PALETTE_STYLES = STYLE_SOURCES.get('_player-palette.scss') ?? '';
type Rgb = [number, number, number];
@@ -211,6 +213,59 @@ describe('player controls overlay palette', () => {
expect(focused).toMatch(/outline:\s*2px solid var\(--pc-text[,)]/);
});
+ it('separates timeline segments at 3:1 over any frame', () => {
+ const track = paletteColor('--pc-timeline-track');
+ const separator = paletteColor('--pc-timeline-separator');
+ const accent = PALETTE_STYLES.match(
+ /\$accent-blue:\s*#([0-9a-f]{6});/i
+ )?.[1];
+ const fill = [0, 2, 4].map((offset) =>
+ Number.parseInt(accent?.slice(offset, offset + 2) ?? '', 16)
+ ) as Rgb;
+
+ // Opaque, so no frame behind the bar can change these ratios.
+ expect(track.alpha).toBe(1);
+ expect(separator.alpha).toBe(1);
+ expect(
+ ruleBody(TIMELINE_STYLES, '.player-controls__timeline-segment')
+ ).toMatch(/background:\s*var\(--pc-timeline-track\);/);
+ expect(
+ ruleBody(TIMELINE_STYLES, '.player-controls__timeline-fill')
+ ).toMatch(/background:\s*var\(--pc-progress\);/);
+ const separatorRule = ruleBody(
+ TIMELINE_STYLES,
+ '.player-controls__timeline-separator'
+ );
+ expect(separatorRule).toMatch(
+ /background:\s*var\(--pc-timeline-separator\);/
+ );
+ // The separator fills exactly the gap the segment widths leave.
+ expect(separatorRule).toMatch(
+ new RegExp(`width:\\s*${TIMELINE_SEGMENT_GAP_PX}px;`)
+ );
+
+ // A boundary in the unplayed and in the played stretch.
+ expect(contrastRatio(separator.rgb, track.rgb)).toBeGreaterThanOrEqual(
+ 3
+ );
+ expect(contrastRatio(separator.rgb, fill)).toBeGreaterThanOrEqual(3);
+ // And the played fill still reads against the track.
+ expect(contrastRatio(fill, track.rgb)).toBeGreaterThanOrEqual(3);
+ });
+
+ it('sets the timeline label on the dense glass', () => {
+ const white: Rgb = [255, 255, 255];
+ expect(
+ ruleBody(TIMELINE_STYLES, '.player-controls__timeline-label')
+ ).toMatch(/background:\s*var\(--pc-glass-bg-dense\);/);
+ expect(
+ contrastRatio(
+ paletteColor('--pc-text').rgb,
+ over(paletteColor('--pc-glass-bg-dense'), white)
+ )
+ ).toBeGreaterThanOrEqual(4.5);
+ });
+
it('keeps the palette reds readable on video', () => {
const white: Rgb = [255, 255, 255];
const glass = paletteColor('--pc-glass-bg');
diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.html b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html
index b28e252c4..34bd7135c 100644
--- a/libs/ui/playback/src/lib/player-controls/player-timeline.component.html
+++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html
@@ -1,12 +1,15 @@
+@let position = formatTime(timeline().value());
+@let positionTitle = timeline().segmentTitle();
{{
- formatTime(timeline().value())
+ position
}}
@if (seekable()) {
@for (segment of timeline().segments(); track $index) {
@@ -24,6 +27,12 @@
[style.width.%]="timeline().fillPercent(segment)"
>
+ @if (segment.separatorLeft; as separatorLeft) {
+
+ }
}
@if (canSeek()) {
@@ -33,19 +42,33 @@
aria-hidden="true"
>
}
- @if (hover().percent() !== null) {
+ @if (label().markerPercent() !== null) {
+ }
+ @if (label().text(); as text) {
- {{ hover().label() }}
+ @if (text.title) {
+ {{ text.title }}
+ }
+ {{ text.time }}
}
@@ -76,13 +107,19 @@
} @else if (isLive()) {
- LIVE
+ {{ 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate }}
} @else {
- --:--
+ {{ unknownTime }}
}
@if (recordingStatus(); as status) {
).nativeElement;
readonly timeline = input.required();
- readonly hover = input.required();
+ readonly label = input.required();
/** The engine can seek at all; without it the track is not drawn. */
readonly seekable = input(false);
/** Seeking is possible right now. */
@@ -41,4 +51,73 @@ export class PlayerTimelineComponent {
readonly scrubCommit = output();
readonly formatTime = formatTime;
+ readonly unknownTime = UNKNOWN_TIME_TEXT;
+
+ private readonly bar = viewChild>('bar');
+ private readonly labelElement =
+ viewChild>('labelElement');
+ /**
+ * Bumped when the bar or the player resizes: the label's pixel `left`
+ * is stale then, even with its anchor and text unchanged (a paused
+ * player, the keyboard holding the slider, a narrowed window).
+ */
+ private readonly resized = signal(0);
+
+ constructor() {
+ effect((onCleanup) => {
+ const bar = this.bar()?.nativeElement;
+ if (!bar || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ const observer = new ResizeObserver(() =>
+ this.resized.update((count) => count + 1)
+ );
+ observer.observe(bar);
+ const player = this.host.closest('.player-controls-host');
+ if (player) {
+ observer.observe(player);
+ }
+ onCleanup(() => observer.disconnect());
+ });
+ // The stylesheet centres the label on its anchor; once laid out, it
+ // is moved by its measured width to stay inside the player, which a
+ // long programme or chapter title would otherwise overflow.
+ afterRenderEffect({
+ earlyRead: () => {
+ const percent = this.label().percent();
+ // A new title or time changes the width to clamp, and a
+ // resize the room it is clamped into.
+ this.label().text();
+ this.resized();
+ const label = this.labelElement()?.nativeElement;
+ const bar = this.bar()?.nativeElement;
+ if (percent === null || !label || !bar) {
+ return null;
+ }
+ const barRect = bar.getBoundingClientRect();
+ if (!(barRect.width > 0)) {
+ return null;
+ }
+ const player = (
+ this.host.closest('.player-controls-host') ?? bar
+ ).getBoundingClientRect();
+ return {
+ label,
+ left: clampTimelineLabelLeft(
+ percent,
+ label.offsetWidth,
+ barRect,
+ player
+ ),
+ };
+ },
+ write: (placement) => {
+ const value = placement();
+ if (value) {
+ value.label.style.left = `${value.left}px`;
+ value.label.style.transform = 'none';
+ }
+ },
+ });
+ }
}
diff --git a/tools/i18n/identical-en-baseline.json b/tools/i18n/identical-en-baseline.json
index be5891794..69c96e745 100644
--- a/tools/i18n/identical-en-baseline.json
+++ b/tools/i18n/identical-en-baseline.json
@@ -4,6 +4,7 @@
"CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer",
"CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
"HOME.PLAYLIST_TYPES.STALKER": "Stalker",
@@ -111,6 +112,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_STALKER": "Stalker Portal",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
@@ -219,6 +221,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
@@ -326,11 +329,13 @@
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"DOWNLOADS.TITLE": "Downloads",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
+ "EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE",
"EMBEDDED_MPV.PLAYER.PAUSE": "Pause",
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
"EMBEDDED_MPV.PLAYER.STATS_CONTAINER": "Container",
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
"EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN": "Cyan",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG.GUIDE.ZOOM": "Zoom",
"EPG.LIVE_STREAM": "Live stream",
"EPG.PROGRAM_DIALOG.EPISODE": "Episode",
@@ -453,6 +458,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.FILE_UPLOAD.OR": "or",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
@@ -569,6 +575,7 @@
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG.GUIDE.ZOOM": "Zoom",
"EPG.TIMELINE.MINUTES_SHORT": "min",
"EPG.TIMELINE.ZOOM": "Zoom",
@@ -701,6 +708,7 @@
"EMBEDDED_MPV.PLAYER.QUALITY_AUTO": "Auto",
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
"EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN": "Cyan",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
"EPG.GUIDE.KEYS.PROGRAMME": "programme",
"EPG.GUIDE.ZOOM": "Zoom",
@@ -837,6 +845,7 @@
"DOWNLOADS.RESUME": "Resume",
"DOWNLOADS.STATUS.PAUSED": "Paused",
"EMBEDDED_MPV.PLAYER.BACK": "Back",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG_MAPPING_DIALOG.CLOSE": "Close",
"EPG_MAPPING_DIALOG.CURRENT_MAPPING": "Current mapping",
"EPG_MAPPING_DIALOG.NO_MAPPING": "No EPG mapping set",
@@ -970,6 +979,7 @@
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
"EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%",
"EPG.GUIDE.ZOOM": "Zoom",
@@ -1100,6 +1110,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
@@ -1203,6 +1214,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG.DOWNLOAD_SUCCESS": "EPG was successfully downloaded.",
"EPG.ERROR": "Ooops, EPG could not be loaded.",
"EPG.LIVE_STREAM": "Live stream",
@@ -1324,11 +1336,13 @@
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"DOWNLOADS.TITLE": "Downloads",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
+ "EMBEDDED_MPV.PLAYER.LIVE_BADGE": "LIVE",
"EMBEDDED_MPV.PLAYER.LIVE_STREAM": "Live stream",
"EMBEDDED_MPV.PLAYER.STATS_AUDIO": "Audio",
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
"EMBEDDED_MPV.PLAYER.STATS_CONTAINER": "Container",
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
"EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%",
"EPG.GUIDE.KEYS.DETAILS": "details",
@@ -1488,6 +1502,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"EMBEDDED_MPV.PLAYER.AUDIO_TRACK_FALLBACK": "Audio {{index}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EPG.TIMELINE.MINUTES_SHORT": "min",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
@@ -1602,6 +1617,7 @@
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"DOWNLOADS.TITLE": "Downloads",
"EMBEDDED_MPV.PLAYER.STATS_BUFFER": "Buffer",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"EMBEDDED_MPV.PLAYER.VOLUME": "Volume",
"EMBEDDED_MPV.PLAYER.VOLUME_LABEL": "Volume {{percent}}%",
"EPG.GUIDE.ZOOM": "Zoom",
@@ -1726,6 +1742,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
@@ -1778,6 +1795,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
"EMBEDDED_MPV.PLAYER.STATS_VIDEO": "Video",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLIST_TYPES.STALKER": "Stalker",
@@ -1886,6 +1904,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"CHANNELS.DETAILS_DIALOG.TVG_URL": "TVG URL",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",
@@ -1988,6 +2007,7 @@
"CHANNELS.DETAILS_DIALOG.TVG_ID": "TVG ID",
"CHANNELS.DETAILS_DIALOG.TVG_REC": "TVG rec",
"DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE": "{{size}}",
+ "EMBEDDED_MPV.PLAYER.TIMELINE_SEGMENT_POSITION": "{{title}} · {{time}}",
"HOME.ADD_PLAYLIST.METHOD_URL_LABEL": "M3U URL",
"HOME.PLAYLISTS.CONTENT_EPG": "EPG",
"HOME.PLAYLISTS.FEATURE_XTREAM": "Xtream Codes",