mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes
This commit is contained in:
1 parent
46c58f4f57
commit
760099358b
188 files changed
+31245
-2281
No files matched your search
+14
-4
@@ -121,6 +121,15 @@
|
||||
<span class="download-activity-bar__fill"></span>
|
||||
</span>
|
||||
}
|
||||
@if (activeDownloadsCount() > 0) {
|
||||
<span
|
||||
class="download-count-badge"
|
||||
data-test-id="global-download-count"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ activeDownloadsCount() }}
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut"
|
||||
@@ -128,11 +137,12 @@
|
||||
[class.is-active]="isDownloadsView()"
|
||||
[class.has-activity]="hasActiveDownloads()"
|
||||
(click)="onDownloadsRequested()"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate
|
||||
"
|
||||
[matTooltip]="'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate
|
||||
('WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate) +
|
||||
(hasActiveDownloads()
|
||||
? ' (' + activeDownloadsCount() + ')'
|
||||
: '')
|
||||
"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
|
||||
+22
@@ -214,6 +214,28 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.download-count-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
z-index: 11;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--mat-sys-surface-container-low);
|
||||
border-radius: 999px;
|
||||
color: var(--mat-sys-on-primary);
|
||||
background: var(--mat-sys-primary);
|
||||
box-shadow: 0 2px 8px var(--app-selection-glow);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.download-activity-bar {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
|
||||
+73
-9
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
@@ -84,8 +82,9 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
it('emits search input changes as user types', () => {
|
||||
const emitted: string[] = [];
|
||||
component.searchChanged.subscribe((value) => emitted.push(value));
|
||||
const input: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="search"]');
|
||||
const input: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
'input[type="search"]'
|
||||
);
|
||||
|
||||
input.value = 'matrix';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
@@ -94,8 +93,9 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
});
|
||||
|
||||
it('focuses and selects the search input on request', () => {
|
||||
const input: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="search"]');
|
||||
const input: HTMLInputElement = fixture.nativeElement.querySelector(
|
||||
'input[type="search"]'
|
||||
);
|
||||
input.value = 'matrix';
|
||||
input.blur();
|
||||
|
||||
@@ -172,4 +172,68 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
|
||||
expect(chips).toEqual(['Movies / All Items', 'Loaded channels only']);
|
||||
});
|
||||
|
||||
it('renders the global active download count without duplicating the button name', () => {
|
||||
fixture.componentRef.setInput('isElectron', true);
|
||||
fixture.componentRef.setInput('activeDownloadsCount', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge: HTMLElement | null = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="global-download-count"]'
|
||||
);
|
||||
|
||||
expect(badge?.textContent?.trim()).toBe('3');
|
||||
expect(badge?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.download-activity-bar')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not render a global download badge when no downloads are active', () => {
|
||||
fixture.componentRef.setInput('isElectron', true);
|
||||
fixture.componentRef.setInput('activeDownloadsCount', 0);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="global-download-count"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 'WORKSPACE.SHELL.OPEN_DOWNLOADS'],
|
||||
[1, 'WORKSPACE.SHELL.OPEN_DOWNLOADS (1)'],
|
||||
[3, 'WORKSPACE.SHELL.OPEN_DOWNLOADS (3)'],
|
||||
])(
|
||||
'exposes %i active downloads in the downloads button accessible name',
|
||||
(activeDownloadsCount, expectedLabel) => {
|
||||
fixture.componentRef.setInput('isElectron', true);
|
||||
fixture.componentRef.setInput(
|
||||
'activeDownloadsCount',
|
||||
activeDownloadsCount
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector(
|
||||
'.download-btn-wrap button'
|
||||
);
|
||||
|
||||
expect(button.getAttribute('aria-label')).toBe(expectedLabel);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses the paired Material primary tokens for the download badge', () => {
|
||||
const styleSource = readFileSync(
|
||||
join(__dirname, 'workspace-shell-header.component.scss'),
|
||||
'utf8'
|
||||
);
|
||||
const badgeStyles = styleSource.match(
|
||||
/\.download-count-badge\s*\{([^}]*)\}/
|
||||
)?.[1];
|
||||
|
||||
expect(badgeStyles).toContain('color: var(--mat-sys-on-primary);');
|
||||
expect(badgeStyles).toContain('background: var(--mat-sys-primary);');
|
||||
});
|
||||
});
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
@@ -52,7 +53,10 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly isElectron = input(false);
|
||||
readonly hasNoPlaylists = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
readonly hasActiveDownloads = input(false);
|
||||
readonly activeDownloadsCount = input(0);
|
||||
readonly hasActiveDownloads = computed(
|
||||
() => this.activeDownloadsCount() > 0
|
||||
);
|
||||
/**
|
||||
* When true the playlist switcher + the "+ Add source" / refresh /
|
||||
* bulk-action buttons are hidden — those controls scope to a
|
||||
|
||||
+15
@@ -376,6 +376,21 @@ describe('WorkspaceShellFacade', () => {
|
||||
expect(facade.isMacOS).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes the global active download count and derives activity when supported', () => {
|
||||
downloadsActiveCountSignal.set(3);
|
||||
|
||||
expect(facade.activeDownloadsCount()).toBe(3);
|
||||
expect(facade.hasActiveDownloads()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the active download count when downloads are unsupported', () => {
|
||||
runtime.supportsDownloads = false;
|
||||
downloadsActiveCountSignal.set(3);
|
||||
|
||||
expect(facade.activeDownloadsCount()).toBe(0);
|
||||
expect(facade.hasActiveDownloads()).toBe(false);
|
||||
});
|
||||
|
||||
it('routes dashboard search Enter into the active Xtream playlist search', () => {
|
||||
const xtreamStore = TestBed.inject(
|
||||
XtreamStore
|
||||
|
||||
+4
-1
@@ -112,8 +112,11 @@ export class WorkspaceShellFacade {
|
||||
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
|
||||
readonly headerBulkAction = this.header.headerBulkAction;
|
||||
readonly playlistSubtitle = this.header.playlistSubtitle;
|
||||
readonly activeDownloadsCount = computed(() =>
|
||||
this.supportsDownloads ? this.downloadsService.activeCount() : 0
|
||||
);
|
||||
readonly hasActiveDownloads = computed(
|
||||
() => this.supportsDownloads && this.downloadsService.activeCount() > 0
|
||||
() => this.activeDownloadsCount() > 0
|
||||
);
|
||||
|
||||
constructor() {
|
||||
|
||||
+7
-10
@@ -1,8 +1,4 @@
|
||||
<div
|
||||
class="workspace-shell"
|
||||
appPlaylistDropZone
|
||||
#dropZone="playlistDropZone"
|
||||
>
|
||||
<div class="workspace-shell" appPlaylistDropZone #dropZone="playlistDropZone">
|
||||
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
|
||||
@if (facade.showXtreamImportOverlay()) {
|
||||
<app-workspace-shell-import-overlay />
|
||||
@@ -39,7 +35,7 @@
|
||||
[isElectron]="facade.isElectron"
|
||||
[hasNoPlaylists]="facade.hasNoPlaylists()"
|
||||
[isDownloadsView]="facade.isDownloadsView()"
|
||||
[hasActiveDownloads]="facade.hasActiveDownloads()"
|
||||
[activeDownloadsCount]="facade.activeDownloadsCount()"
|
||||
[isSettingsRoute]="facade.isSettingsRoute()"
|
||||
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
|
||||
(searchChanged)="facade.onSearchInput($event)"
|
||||
@@ -54,7 +50,10 @@
|
||||
(accountInfoRequested)="facade.openAccountInfo()"
|
||||
/>
|
||||
|
||||
<div class="workspace-body" [class.single-pane]="!facade.showContextPanel()">
|
||||
<div
|
||||
class="workspace-body"
|
||||
[class.single-pane]="!facade.showContextPanel()"
|
||||
>
|
||||
@if (facade.showContextPanel()) {
|
||||
<app-workspace-shell-context-sidebar
|
||||
[variant]="facade.contextPanel()"
|
||||
@@ -75,9 +74,7 @@
|
||||
<app-external-playback-dock
|
||||
[session]="session"
|
||||
(closeClicked)="facade.closeActiveExternalSession()"
|
||||
(artworkClicked)="
|
||||
facade.openActiveExternalSessionTarget()
|
||||
"
|
||||
(artworkClicked)="facade.openActiveExternalSessionTarget()"
|
||||
/>
|
||||
</footer>
|
||||
}
|
||||
|
||||
+7
-9
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Component, Directive, input, output, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterOutlet, provideRouter } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -58,7 +52,7 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly isElectron = input(false);
|
||||
readonly hasNoPlaylists = input(false);
|
||||
readonly isDownloadsView = input(false);
|
||||
readonly hasActiveDownloads = input(false);
|
||||
readonly activeDownloadsCount = input(0);
|
||||
readonly isSettingsRoute = input(false);
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly searchChanged = output<string>();
|
||||
@@ -154,7 +148,7 @@ class MockWorkspaceShellFacade {
|
||||
readonly isRefreshingPlaylist = signal(false);
|
||||
readonly hasNoPlaylists = signal(false);
|
||||
readonly isDownloadsView = signal(false);
|
||||
readonly hasActiveDownloads = signal(false);
|
||||
readonly activeDownloadsCount = signal(3);
|
||||
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly showContextPanel = signal(true);
|
||||
readonly contextPanel = signal<WorkspaceShellContextPanel>('settings');
|
||||
@@ -246,6 +240,10 @@ describe('WorkspaceShellComponent', () => {
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-workspace-shell-header')
|
||||
).not.toBeNull();
|
||||
const header = fixture.debugElement.query(
|
||||
By.directive(MockWorkspaceShellHeaderComponent)
|
||||
).componentInstance as MockWorkspaceShellHeaderComponent;
|
||||
expect(header.activeDownloadsCount()).toBe(3);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'app-workspace-shell-context-sidebar'
|
||||
|
||||
@@ -54,6 +54,199 @@ describe('workspace-shell-route.utils', () => {
|
||||
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
section: null,
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('disables collection shell state for focused download details', () => {
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/downloads/42?q=x#details')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
context: null,
|
||||
section: null,
|
||||
contextPanel: 'none',
|
||||
searchMode: 'none',
|
||||
usesQuerySearch: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads/42')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
context: {
|
||||
provider: 'xtreams',
|
||||
playlistId: 'pl-1',
|
||||
},
|
||||
section: 'downloads',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'none',
|
||||
usesQuerySearch: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/stalker/pl-1/downloads/42')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
context: {
|
||||
provider: 'stalker',
|
||||
playlistId: 'pl-1',
|
||||
},
|
||||
section: 'downloads',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'none',
|
||||
usesQuerySearch: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'keeps Xtream query state before a fragment',
|
||||
'/workspace/xtreams/pl-1/favorites?scope=all#details',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'keeps Stalker query state before a fragment',
|
||||
'/workspace/stalker/pl-1/favorites?scope=all#details',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'ignores query-like text inside a fragment',
|
||||
'/workspace/xtreams/pl-1/favorites#details?scope=all',
|
||||
false,
|
||||
],
|
||||
])('%s', (_name, url, expectedAllScope) => {
|
||||
expect(parseWorkspaceShellRoute(url).isPortalFavoritesAllScope).toBe(
|
||||
expectedAllScope
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps manager shell state outside exact focused download routes', () => {
|
||||
expect(parseWorkspaceShellRoute('/workspace/downloads/')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/downloads/42/extra')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute(
|
||||
'/workspace/xtreams/pl-1/downloads/42/extra'
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/stalker/pl-1/downloads')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute(
|
||||
'/workspace/stalker/pl-1/downloads/42/extra'
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores fragments when identifying download manager routes', () => {
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/downloads/#details')
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute(
|
||||
'/workspace/xtreams/pl-1/downloads/#details'
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute(
|
||||
'/workspace/stalker/pl-1/downloads/#details'
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
section: 'downloads',
|
||||
contextPanel: 'collection',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
parseWorkspaceShellRoute(
|
||||
'/workspace/downloads/#details?query=ignored'
|
||||
)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'downloads',
|
||||
contextPanel: 'none',
|
||||
searchMode: 'local-filter',
|
||||
usesQuerySearch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -103,9 +296,7 @@ describe('workspace-shell-route.utils', () => {
|
||||
});
|
||||
|
||||
it('parses Stalker portal routes and detects context-panel/query-search state', () => {
|
||||
expect(
|
||||
parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')
|
||||
).toEqual(
|
||||
expect(parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'portal',
|
||||
context: {
|
||||
|
||||
@@ -15,11 +15,7 @@ export type WorkspaceShellPageKind =
|
||||
| 'unknown';
|
||||
|
||||
export type WorkspaceShellContextPanel =
|
||||
| 'none'
|
||||
| 'sources'
|
||||
| 'settings'
|
||||
| 'category'
|
||||
| 'collection';
|
||||
'none' | 'sources' | 'settings' | 'category' | 'collection';
|
||||
|
||||
export interface WorkspacePortalContext {
|
||||
provider: PortalProvider;
|
||||
@@ -27,10 +23,7 @@ export interface WorkspacePortalContext {
|
||||
}
|
||||
|
||||
export type WorkspaceShellSearchMode =
|
||||
| 'none'
|
||||
| 'local-filter'
|
||||
| 'remote-search'
|
||||
| 'advanced-only';
|
||||
'none' | 'local-filter' | 'remote-search' | 'advanced-only';
|
||||
|
||||
export interface WorkspaceShellRoute {
|
||||
kind: WorkspaceShellPageKind;
|
||||
@@ -93,7 +86,8 @@ function normalizePath(url: string): {
|
||||
segments: string[];
|
||||
queryParams: URLSearchParams;
|
||||
} {
|
||||
const [path, query = ''] = url.split('?');
|
||||
const [urlWithoutFragment] = url.split('#');
|
||||
const [path, query = ''] = urlWithoutFragment.split('?');
|
||||
return {
|
||||
segments: path.split('/').filter(Boolean),
|
||||
queryParams: new URLSearchParams(query),
|
||||
@@ -266,6 +260,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
|
||||
playlistId: segments[2],
|
||||
};
|
||||
const section = asPortalRailSection(sectionSegment);
|
||||
const isFocusedDownloadDetail =
|
||||
(provider === 'xtreams' || provider === 'stalker') &&
|
||||
section === 'downloads' &&
|
||||
segments.length === 5 &&
|
||||
Boolean(segments[4]);
|
||||
|
||||
return {
|
||||
kind: 'portal',
|
||||
@@ -275,9 +274,15 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
|
||||
section === 'favorites' &&
|
||||
(provider === 'xtreams' || provider === 'stalker') &&
|
||||
queryParams.get('scope') === 'all',
|
||||
searchMode: resolveRouteSearchMode('portal', context, section),
|
||||
usesQuerySearch: usesWorkspaceRouteQuerySearch(context, section),
|
||||
contextPanel: resolveContextPanel('portal', context, section),
|
||||
searchMode: isFocusedDownloadDetail
|
||||
? 'none'
|
||||
: resolveRouteSearchMode('portal', context, section),
|
||||
usesQuerySearch: isFocusedDownloadDetail
|
||||
? false
|
||||
: usesWorkspaceRouteQuerySearch(context, section),
|
||||
contextPanel: isFocusedDownloadDetail
|
||||
? 'none'
|
||||
: resolveContextPanel('portal', context, section),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,7 +303,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
|
||||
: page === 'downloads'
|
||||
? 'downloads'
|
||||
: 'unknown';
|
||||
const searchMode = resolveRouteSearchMode(kind, null, null);
|
||||
const isFocusedDownloadDetail =
|
||||
kind === 'downloads' && segments.length === 3 && Boolean(segments[2]);
|
||||
const searchMode = isFocusedDownloadDetail
|
||||
? 'none'
|
||||
: resolveRouteSearchMode(kind, null, null);
|
||||
|
||||
return {
|
||||
kind,
|
||||
@@ -308,6 +317,8 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
|
||||
searchMode,
|
||||
usesQuerySearch:
|
||||
searchMode === 'local-filter' || searchMode === 'remote-search',
|
||||
contextPanel: resolveContextPanel(kind, null, null),
|
||||
contextPanel: isFocusedDownloadDetail
|
||||
? 'none'
|
||||
: resolveContextPanel(kind, null, null),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user