mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* fix(remote-control): use iptvnator favicon * feat(playback): add embedded mpv series navigation * refactor(playback): share series navigation state
561 lines
25 KiB
HTML
561 lines
25 KiB
HTML
<div
|
|
#playerRoot
|
|
class="embedded-mpv-player"
|
|
[class.embedded-mpv-player--fullscreen]="isFullscreen()"
|
|
[class.embedded-mpv-player--controls-enabled]="
|
|
showControls() && isSupported()
|
|
"
|
|
[class.embedded-mpv-player--controls-visible]="controlsAreVisible()"
|
|
[class.embedded-mpv-player--cursor-hidden]="hideCursor()"
|
|
(pointerenter)="onPlayerInteraction()"
|
|
(pointermove)="onPlayerInteraction()"
|
|
(click)="onPlayerInteraction()"
|
|
(dblclick)="onPlayerDblClick($event)"
|
|
(focusin)="onPlayerInteraction()"
|
|
>
|
|
<div #viewport class="embedded-mpv-player__viewport">
|
|
@if (isErrored()) {
|
|
<div
|
|
class="embedded-mpv-player__stalled"
|
|
role="alert"
|
|
aria-live="assertive"
|
|
>
|
|
<mat-icon>error_outline</mat-icon>
|
|
<p>{{ statusLabel() }}</p>
|
|
<button mat-stroked-button type="button" (click)="retry()">
|
|
<mat-icon>refresh</mat-icon>
|
|
<span>Retry</span>
|
|
</button>
|
|
</div>
|
|
} @else if (isLoading() && !stalled()) {
|
|
<div
|
|
class="embedded-mpv-player__loader"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<mat-progress-spinner
|
|
mode="indeterminate"
|
|
diameter="48"
|
|
strokeWidth="3"
|
|
/>
|
|
@if (statusLabel()) {
|
|
<span>{{ statusLabel() }}</span>
|
|
}
|
|
</div>
|
|
} @else if (stalled()) {
|
|
<div
|
|
class="embedded-mpv-player__stalled"
|
|
role="alert"
|
|
aria-live="assertive"
|
|
>
|
|
<mat-icon>warning_amber</mat-icon>
|
|
<p>This stream is taking longer than expected to start.</p>
|
|
<button mat-stroked-button type="button" (click)="retry()">
|
|
<mat-icon>refresh</mat-icon>
|
|
<span>Retry</span>
|
|
</button>
|
|
</div>
|
|
} @else if (statusLabel()) {
|
|
<div
|
|
class="embedded-mpv-player__status"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
{{ statusLabel() }}
|
|
</div>
|
|
}
|
|
|
|
@if (feedback.current(); as feedback) {
|
|
<div
|
|
class="embedded-mpv-player__feedback"
|
|
[attr.data-feedback-key]="feedback.key"
|
|
aria-hidden="true"
|
|
>
|
|
<mat-icon>{{ feedback.icon }}</mat-icon>
|
|
<span>{{ feedback.label }}</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (showControls() && isSupported()) {
|
|
<div
|
|
class="embedded-mpv-player__controls"
|
|
[class.embedded-mpv-player__controls--visible]="
|
|
controlsAreVisible()
|
|
"
|
|
>
|
|
<div class="embedded-mpv-player__transport">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
[disabled]="isLoading()"
|
|
(click)="togglePaused()"
|
|
[attr.aria-label]="isPaused() ? 'Play' : 'Pause'"
|
|
[matTooltip]="isPaused() ? 'Play' : 'Pause'"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>{{
|
|
isPaused() ? 'play_arrow' : 'pause'
|
|
}}</mat-icon>
|
|
</button>
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
data-test-id="embedded-mpv-previous-episode"
|
|
[disabled]="isLoading() || !canPreviousEpisode()"
|
|
(click)="requestPreviousEpisode()"
|
|
aria-label="Previous episode"
|
|
matTooltip="Previous episode"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>skip_previous</mat-icon>
|
|
</button>
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
data-test-id="embedded-mpv-next-episode"
|
|
[disabled]="isLoading() || !canNextEpisode()"
|
|
(click)="requestNextEpisode()"
|
|
aria-label="Next episode"
|
|
matTooltip="Next episode"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>skip_next</mat-icon>
|
|
</button>
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
[disabled]="!canSeek()"
|
|
(click)="seekBy(-10)"
|
|
aria-label="Back 10 seconds"
|
|
matTooltip="Back 10 seconds"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>replay_10</mat-icon>
|
|
</button>
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
[disabled]="!canSeek()"
|
|
(click)="seekBy(10)"
|
|
aria-label="Forward 10 seconds"
|
|
matTooltip="Forward 10 seconds"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>forward_10</mat-icon>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="embedded-mpv-player__timeline">
|
|
<input
|
|
class="embedded-mpv-player__slider"
|
|
[style.--slider-progress.%]="
|
|
canSeek()
|
|
? (timelineValue() /
|
|
((session()?.durationSeconds ?? 0) || 1)) *
|
|
100
|
|
: 0
|
|
"
|
|
type="range"
|
|
min="0"
|
|
[max]="session()?.durationSeconds ?? 0"
|
|
[value]="timelineValue()"
|
|
[disabled]="!canSeek()"
|
|
[attr.aria-valuetext]="formatTime(timelineValue())"
|
|
(input)="onTimelineInput($event)"
|
|
/>
|
|
<div class="embedded-mpv-player__time">
|
|
<span>{{ formatTime(session()?.positionSeconds) }}</span>
|
|
@if (canSeek()) {
|
|
<span>{{
|
|
formatTime(session()?.durationSeconds)
|
|
}}</span>
|
|
} @else {
|
|
<span
|
|
class="embedded-mpv-player__live-badge"
|
|
aria-label="Live stream"
|
|
>
|
|
<span
|
|
class="embedded-mpv-player__live-dot"
|
|
aria-hidden="true"
|
|
></span>
|
|
LIVE
|
|
</span>
|
|
}
|
|
</div>
|
|
@if (recordingStatusText(); as recordingStatus) {
|
|
<div
|
|
class="embedded-mpv-player__recording-status"
|
|
[class.embedded-mpv-player__recording-status--active]="
|
|
isRecording()
|
|
"
|
|
[attr.aria-live]="isRecording() ? 'off' : 'polite'"
|
|
>
|
|
<mat-icon>{{
|
|
isRecording()
|
|
? 'fiber_manual_record'
|
|
: 'check_circle'
|
|
}}</mat-icon>
|
|
<span>{{ recordingStatus }}</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="embedded-mpv-player__actions">
|
|
<div
|
|
class="embedded-mpv-player__popover-anchor"
|
|
(pointerenter)="onVolumeHoverEnter()"
|
|
(pointerleave)="onVolumeHoverLeave()"
|
|
(focusin)="onVolumeHoverEnter()"
|
|
(focusout)="onVolumeHoverLeave()"
|
|
(wheel)="onVolumeWheel($event)"
|
|
>
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
(click)="toggleMute()"
|
|
[attr.aria-label]="volume() <= 0 ? 'Unmute' : 'Mute'"
|
|
[matTooltip]="volume() <= 0 ? 'Unmute' : 'Mute'"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>{{ volumeIcon() }}</mat-icon>
|
|
</button>
|
|
@if (menus.volumeOpen()) {
|
|
<div
|
|
class="embedded-mpv-player__control-panel embedded-mpv-player__volume-popover"
|
|
role="dialog"
|
|
aria-label="Volume"
|
|
>
|
|
<input
|
|
class="embedded-mpv-player__slider embedded-mpv-player__slider--volume"
|
|
[style.--slider-progress.%]="volume() * 100"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
[value]="volume()"
|
|
[attr.aria-label]="volumeLabel()"
|
|
[attr.aria-valuetext]="volumeLabel()"
|
|
(input)="onVolumeInput($event)"
|
|
/>
|
|
<span class="embedded-mpv-player__volume-value">
|
|
{{ volumeLabel() }}
|
|
</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (hasAudioTracks()) {
|
|
<div class="embedded-mpv-player__popover-anchor">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
(click)="toggleAudioMenu()"
|
|
[attr.aria-expanded]="menus.audioOpen()"
|
|
aria-label="Audio tracks"
|
|
matTooltip="Audio tracks"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>graphic_eq</mat-icon>
|
|
</button>
|
|
@if (menus.audioOpen()) {
|
|
<div
|
|
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
|
role="menu"
|
|
aria-label="Audio tracks"
|
|
>
|
|
<div class="embedded-mpv-player__menu-title">
|
|
Audio tracks
|
|
</div>
|
|
<div
|
|
class="embedded-mpv-player__audio-track-list"
|
|
>
|
|
@for (
|
|
track of audioTracks();
|
|
track track.id;
|
|
let index = $index
|
|
) {
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
[attr.aria-checked]="track.selected"
|
|
class="embedded-mpv-player__audio-track"
|
|
[class.embedded-mpv-player__audio-track--selected]="
|
|
track.selected
|
|
"
|
|
(click)="selectAudioTrack(track.id)"
|
|
>
|
|
<span>{{
|
|
trackLabel(track, index)
|
|
}}</span>
|
|
@if (track.selected) {
|
|
<mat-icon>check</mat-icon>
|
|
}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (capabilities().subtitles && hasSubtitleTracks()) {
|
|
<div class="embedded-mpv-player__popover-anchor">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
(click)="toggleSubtitleMenu()"
|
|
[attr.aria-expanded]="menus.subtitleOpen()"
|
|
aria-label="Subtitles"
|
|
matTooltip="Subtitles"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>{{
|
|
selectedSubtitleTrackId() === null
|
|
? 'subtitles_off'
|
|
: 'subtitles'
|
|
}}</mat-icon>
|
|
</button>
|
|
@if (menus.subtitleOpen()) {
|
|
<div
|
|
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
|
role="menu"
|
|
aria-label="Subtitles"
|
|
>
|
|
<div class="embedded-mpv-player__menu-title">
|
|
Subtitles
|
|
</div>
|
|
<div
|
|
class="embedded-mpv-player__audio-track-list"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
[attr.aria-checked]="
|
|
selectedSubtitleTrackId() === null
|
|
"
|
|
class="embedded-mpv-player__audio-track"
|
|
[class.embedded-mpv-player__audio-track--selected]="
|
|
selectedSubtitleTrackId() === null
|
|
"
|
|
(click)="selectSubtitleTrack(-1)"
|
|
>
|
|
<span>Off</span>
|
|
@if (
|
|
selectedSubtitleTrackId() === null
|
|
) {
|
|
<mat-icon>check</mat-icon>
|
|
}
|
|
</button>
|
|
@for (
|
|
track of subtitleTracks();
|
|
track track.id;
|
|
let index = $index
|
|
) {
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
[attr.aria-checked]="track.selected"
|
|
class="embedded-mpv-player__audio-track"
|
|
[class.embedded-mpv-player__audio-track--selected]="
|
|
track.selected
|
|
"
|
|
(click)="
|
|
selectSubtitleTrack(track.id)
|
|
"
|
|
>
|
|
<span>{{
|
|
subtitleLabel(track, index)
|
|
}}</span>
|
|
@if (track.selected) {
|
|
<mat-icon>check</mat-icon>
|
|
}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (capabilities().playbackSpeed) {
|
|
<div class="embedded-mpv-player__popover-anchor">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
(click)="toggleSpeedMenu()"
|
|
[attr.aria-expanded]="menus.speedOpen()"
|
|
[attr.aria-label]="
|
|
'Playback speed ' + speedLabel(playbackSpeed())
|
|
"
|
|
[matTooltip]="
|
|
'Speed: ' + speedLabel(playbackSpeed())
|
|
"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>speed</mat-icon>
|
|
</button>
|
|
@if (menus.speedOpen()) {
|
|
<div
|
|
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
|
role="menu"
|
|
aria-label="Playback speed"
|
|
>
|
|
<div class="embedded-mpv-player__menu-title">
|
|
Speed
|
|
</div>
|
|
<div
|
|
class="embedded-mpv-player__audio-track-list"
|
|
>
|
|
@for (
|
|
preset of speedPresets;
|
|
track preset.value
|
|
) {
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
[attr.aria-checked]="
|
|
preset.value === playbackSpeed()
|
|
"
|
|
class="embedded-mpv-player__audio-track"
|
|
[class.embedded-mpv-player__audio-track--selected]="
|
|
preset.value === playbackSpeed()
|
|
"
|
|
(click)="selectSpeed(preset.value)"
|
|
>
|
|
<span>{{ preset.label }}</span>
|
|
@if (
|
|
preset.value === playbackSpeed()
|
|
) {
|
|
<mat-icon>check</mat-icon>
|
|
}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (capabilities().aspectOverride) {
|
|
<div class="embedded-mpv-player__popover-anchor">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
(click)="toggleAspectMenu()"
|
|
[attr.aria-expanded]="menus.aspectOpen()"
|
|
[attr.aria-label]="
|
|
'Aspect ratio ' + aspectLabel(aspectOverride())
|
|
"
|
|
[matTooltip]="
|
|
'Aspect: ' + aspectLabel(aspectOverride())
|
|
"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>aspect_ratio</mat-icon>
|
|
</button>
|
|
@if (menus.aspectOpen()) {
|
|
<div
|
|
class="embedded-mpv-player__control-panel embedded-mpv-player__audio-popover"
|
|
role="menu"
|
|
aria-label="Aspect ratio"
|
|
>
|
|
<div class="embedded-mpv-player__menu-title">
|
|
Aspect
|
|
</div>
|
|
<div
|
|
class="embedded-mpv-player__audio-track-list"
|
|
>
|
|
@for (
|
|
preset of aspectPresets;
|
|
track preset.value
|
|
) {
|
|
<button
|
|
type="button"
|
|
role="menuitemradio"
|
|
[attr.aria-checked]="
|
|
preset.value ===
|
|
aspectOverride()
|
|
"
|
|
class="embedded-mpv-player__audio-track"
|
|
[class.embedded-mpv-player__audio-track--selected]="
|
|
preset.value ===
|
|
aspectOverride()
|
|
"
|
|
(click)="selectAspect(preset.value)"
|
|
>
|
|
<span>{{ preset.label }}</span>
|
|
@if (
|
|
preset.value ===
|
|
aspectOverride()
|
|
) {
|
|
<mat-icon>check</mat-icon>
|
|
}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (canRecord()) {
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button embedded-mpv-player__record-button"
|
|
[class.embedded-mpv-player__record-button--active]="
|
|
isRecording()
|
|
"
|
|
[disabled]="isLoading()"
|
|
(click)="toggleRecording()"
|
|
[attr.aria-pressed]="isRecording()"
|
|
[attr.aria-label]="
|
|
isRecording() ? 'Stop recording' : 'Start recording'
|
|
"
|
|
[matTooltip]="
|
|
isRecording() ? 'Stop recording' : 'Start recording'
|
|
"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>{{
|
|
isRecording()
|
|
? 'stop_circle'
|
|
: 'fiber_manual_record'
|
|
}}</mat-icon>
|
|
</button>
|
|
}
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
class="embedded-mpv-player__control-button"
|
|
[disabled]="!canFullscreen()"
|
|
(click)="toggleFullscreen()"
|
|
[attr.aria-label]="fullscreenLabel()"
|
|
[matTooltip]="fullscreenLabel()"
|
|
matTooltipPosition="above"
|
|
>
|
|
<mat-icon>{{
|
|
isFullscreen() ? 'fullscreen_exit' : 'fullscreen'
|
|
}}</mat-icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|