mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix double-click external playback handling
This commit is contained in:
1 parent
626948f2f5
commit
e350a2ea96
10 files changed
+184
-27
No files matched your search
@@ -69,20 +69,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
@if (isDesktop()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-test-id="external-player-double-click-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{
|
||||
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK'
|
||||
| translate
|
||||
}}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="openStreamOnDoubleClick"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="openStreamOnDoubleClick"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="setting-item">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { StreamFormat, VideoPlayer } from 'shared-interfaces';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
|
||||
describe('SettingsPlaybackSectionComponent', () => {
|
||||
let fixture: ComponentFixture<SettingsPlaybackSectionComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
SettingsPlaybackSectionComponent,
|
||||
NoopAnimationsModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
|
||||
fixture.componentRef.setInput('form', createForm());
|
||||
fixture.componentRef.setInput('activeSection', 'playback');
|
||||
fixture.componentRef.setInput('players', [
|
||||
{
|
||||
id: VideoPlayer.VideoJs,
|
||||
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
|
||||
});
|
||||
|
||||
it('hides the external-player double-click option outside desktop builds', () => {
|
||||
fixture.componentRef.setInput('isDesktop', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="external-player-double-click-setting"]'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK'
|
||||
);
|
||||
});
|
||||
|
||||
it('labels the double-click option as external-player behavior on desktop', () => {
|
||||
fixture.componentRef.setInput('isDesktop', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="external-player-double-click-setting"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createForm(): FormGroup {
|
||||
return new FormGroup({
|
||||
player: new FormControl(VideoPlayer.VideoJs),
|
||||
streamFormat: new FormControl(StreamFormat.M3u8StreamFormat),
|
||||
openStreamOnDoubleClick: new FormControl(false),
|
||||
showExternalPlaybackBar: new FormControl(true),
|
||||
});
|
||||
}
|
||||
@@ -244,8 +244,8 @@
|
||||
"NEW_VERSION_AVAILABLE": "There is a new version available",
|
||||
"LATEST_VERSION": "You are using the latest version",
|
||||
"STREAM_FORMAT": "Stream Format",
|
||||
"OPEN_STREAM_ON_DOUBLE_CLICK": "Open streams with double-click",
|
||||
"OPEN_STREAM_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before playback starts.",
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Start MPV/VLC with double-click",
|
||||
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before MPV or VLC playback starts.",
|
||||
"LANGUAGE": "Language",
|
||||
"SETTINGS_SAVED": "Success! Configuration was saved.",
|
||||
"THEME": "Visual theme",
|
||||
|
||||
@@ -119,6 +119,7 @@ Implemented behavior:
|
||||
- `onRemoteControlCommand` (number + volume)
|
||||
- Applies channel up/down by active channel URL over `channels$`
|
||||
- Applies number select through existing `switchToChannelByNumber(...)`
|
||||
- Dispatches remote channel changes as explicit playback requests so MPV/VLC starts immediately even when mouse channel rows require double-click before external playback.
|
||||
- Applies volume commands:
|
||||
- up/down in 0.1 increments
|
||||
- toggle mute with last non-zero volume restore
|
||||
@@ -143,10 +144,10 @@ Implemented behavior:
|
||||
- Up/down:
|
||||
- Uses selected live item `selectedItem().xtream_id`
|
||||
- Navigates inside `selectItemsFromSelectedCategory()`
|
||||
- Calls `playLive(nextItem)`
|
||||
- Calls `playLive(nextItem, true)` so remote actions explicitly start playback
|
||||
- Number select:
|
||||
- Maps number to item in current category list
|
||||
- Calls `playLive(channel)`
|
||||
- Calls `playLive(channel, true)` so remote actions explicitly start playback
|
||||
- Publishes status via effect:
|
||||
- `portal: 'xtream'`
|
||||
- `isLiveView` only when selected content type is `live` and item is selected
|
||||
@@ -166,10 +167,10 @@ Implemented behavior:
|
||||
- Up/down:
|
||||
- Uses `selectedItem().id`
|
||||
- Navigates inside `itvChannels()`
|
||||
- Calls `playChannel(nextItem)`
|
||||
- Calls `playChannel(nextItem, true)` so remote actions explicitly start playback
|
||||
- Number select:
|
||||
- Maps number into `itvChannels()`
|
||||
- Calls `playChannel(channel)`
|
||||
- Calls `playChannel(channel, true)` so remote actions explicitly start playback
|
||||
- Publishes status via effect:
|
||||
- `portal: 'stalker'`
|
||||
- `isLiveView` only for selected content type `itv` with active item
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { ChannelActions } from 'm3u-state';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions';
|
||||
|
||||
describe('createM3uChannelPlaybackRequest', () => {
|
||||
it('marks channel activation as an explicit playback request', () => {
|
||||
const channel = {
|
||||
id: 'channel-1',
|
||||
name: 'Sample TV',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
} as Channel;
|
||||
|
||||
expect(createM3uChannelPlaybackRequest(channel)).toEqual(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel,
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ChannelActions } from 'm3u-state';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
|
||||
export function createM3uChannelPlaybackRequest(channel: Channel) {
|
||||
return ChannelActions.setActiveChannel({
|
||||
channel,
|
||||
startPlayback: true,
|
||||
});
|
||||
}
|
||||
+32
-2
@@ -495,14 +495,44 @@ describe('VideoPlayerComponent', () => {
|
||||
expect(overlayRef.attach).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('switches channels by number through the store action', () => {
|
||||
it('switches channels by number through a playback request', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.switchToChannelByNumber(1);
|
||||
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
ChannelActions.setActiveChannel({ channel: sampleChannel })
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: sampleChannel,
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('changes channels from remote navigation through a playback request', () => {
|
||||
const nextChannel = {
|
||||
...sampleChannel,
|
||||
id: 'channel-2',
|
||||
url: 'http://localhost/next.m3u8',
|
||||
name: 'Next TV',
|
||||
};
|
||||
activeChannel.set(sampleChannel);
|
||||
activeChannel$.next(sampleChannel);
|
||||
channels.set([sampleChannel, nextChannel]);
|
||||
channels$.next([sampleChannel, nextChannel]);
|
||||
fixture.detectChanges();
|
||||
|
||||
(
|
||||
component as unknown as {
|
||||
handleRemoteChannelChange(direction: 'up' | 'down'): void;
|
||||
}
|
||||
).handleRemoteChannelChange('down');
|
||||
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: nextChannel,
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions';
|
||||
|
||||
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
|
||||
const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width';
|
||||
@@ -459,9 +460,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: nextChannel,
|
||||
})
|
||||
createM3uChannelPlaybackRequest(nextChannel)
|
||||
);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -739,7 +738,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
.subscribe((channel) => {
|
||||
if (channel) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setActiveChannel({ channel })
|
||||
createM3uChannelPlaybackRequest(channel)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+11
@@ -403,6 +403,17 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach a dangling finally cleanup to failed playback resolution', async () => {
|
||||
const playbackError = new Error('nothing_to_play');
|
||||
const playbackPromise = Promise.reject(playbackError);
|
||||
const finallySpy = jest.spyOn(playbackPromise, 'finally');
|
||||
resolveItvPlayback.mockReturnValue(playbackPromise);
|
||||
|
||||
await component.playChannel(itvChannels()[0]);
|
||||
|
||||
expect(finallySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts external playback from remote channel navigation when double-click opening is enabled', async () => {
|
||||
settingsStore.openStreamOnDoubleClick.set(true);
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
|
||||
|
||||
+4
-2
@@ -404,11 +404,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
const promise = this.stalkerStore.resolveItvPlayback(item);
|
||||
this.playbackResolution = { channelId, promise };
|
||||
|
||||
void promise.finally(() => {
|
||||
const cleanup = () => {
|
||||
if (this.playbackResolution?.promise === promise) {
|
||||
this.playbackResolution = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void promise.then(cleanup, cleanup);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user