diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.component.html b/apps/web/src/app/shared/navigation/portal-rail-links.component.html
new file mode 100644
index 000000000..8b9cbaec3
--- /dev/null
+++ b/apps/web/src/app/shared/navigation/portal-rail-links.component.html
@@ -0,0 +1,40 @@
+@if (variant() === 'list') {
+
+ @for (link of links(); track link.path.join('/')) {
+
+ {{ resolveIcon(link) }}
+
+ }
+
+} @else {
+
+}
diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.component.scss b/apps/web/src/app/shared/navigation/portal-rail-links.component.scss
new file mode 100644
index 000000000..5e2770b61
--- /dev/null
+++ b/apps/web/src/app/shared/navigation/portal-rail-links.component.scss
@@ -0,0 +1,103 @@
+:host {
+ display: block;
+ width: 100%;
+ --rail-link-color: var(--mat-sys-on-surface-variant);
+ --rail-link-hover-color: var(--mat-sys-on-surface);
+ --rail-link-hover-bg: var(--mat-sys-surface-container);
+ /* Keep consistent with category-item.selected tone */
+ --rail-link-active-bg: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
+ --rail-link-active-color: var(--mat-sys-primary);
+ --rail-link-active-icon: var(--mat-sys-primary);
+ --rail-link-active-border: color-mix(in srgb, var(--mat-sys-primary) 42%, transparent);
+ --rail-link-active-glow: color-mix(in srgb, var(--mat-sys-primary) 24%, transparent);
+}
+
+.p-0.m-0 {
+
+ .mdc-list-item {
+ min-height: 28px;
+ height: 28px;
+ padding: 0 !important;
+ border-radius: 4px;
+ margin: 2px 0;
+ display: flex;
+ justify-content: center;
+ color: var(--rail-link-color);
+
+ .mdc-list-item__start {
+ margin-inline-end: 0 !important;
+ margin-inline-start: 0 !important;
+ }
+
+ .mdc-list-item__content {
+ display: none;
+ }
+ }
+
+ mat-icon.mat-mdc-list-item-icon,
+ mat-icon {
+ font-size: 18px !important;
+ width: 18px !important;
+ height: 18px !important;
+ }
+
+ .mdc-list-item.active,
+ .mdc-list-item.is-active {
+ background: var(--rail-link-active-bg);
+ color: var(--rail-link-active-color);
+ box-shadow:
+ inset 0 0 0 1px var(--rail-link-active-border),
+ 0 8px 18px -14px var(--rail-link-active-glow);
+
+ mat-icon {
+ color: var(--rail-link-active-icon);
+ }
+ }
+}
+
+nav.flex a {
+ width: 44px;
+ height: 44px;
+ border-radius: 14px;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--rail-link-color);
+ text-decoration: none;
+ transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
+ app-region: no-drag;
+}
+
+nav.flex a:hover {
+ color: var(--rail-link-hover-color);
+ background: var(--rail-link-hover-bg);
+}
+
+nav.flex a.is-active,
+nav.flex a.active {
+ color: var(--rail-link-active-color);
+ background: var(--rail-link-active-bg);
+ box-shadow:
+ inset 0 0 0 1px var(--rail-link-active-border),
+ 0 10px 20px -16px var(--rail-link-active-glow);
+}
+
+nav.flex a.is-active mat-icon,
+nav.flex a.active mat-icon {
+ color: var(--rail-link-active-icon);
+}
+
+nav.flex a.is-active::after,
+nav.flex a.active::after {
+ content: '';
+ position: absolute;
+ right: -4px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 18px;
+ border-radius: 999px;
+ background: var(--rail-link-active-icon);
+ box-shadow: 0 0 10px var(--rail-link-active-glow);
+}
diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.component.ts b/apps/web/src/app/shared/navigation/portal-rail-links.component.ts
new file mode 100644
index 000000000..c77b8b395
--- /dev/null
+++ b/apps/web/src/app/shared/navigation/portal-rail-links.component.ts
@@ -0,0 +1,52 @@
+import { Component, inject, input } from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+import { MatListModule } from '@angular/material/list';
+import { MatTooltip } from '@angular/material/tooltip';
+import {
+ IsActiveMatchOptions,
+ Router,
+ RouterLink,
+ RouterLinkActive,
+} from '@angular/router';
+import { PortalRailLink } from './portal-rail-links';
+
+@Component({
+ selector: 'app-portal-rail-links',
+ imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive],
+ templateUrl: './portal-rail-links.component.html',
+ styleUrl: './portal-rail-links.component.scss',
+})
+export class PortalRailLinksComponent {
+ private readonly router = inject(Router);
+
+ readonly links = input([]);
+ readonly selectedSection = input(null);
+ readonly activeClass = input<'active' | 'is-active'>('active');
+ readonly variant = input<'list' | 'rail'>('list');
+
+ resolveIcon(link: PortalRailLink): string {
+ const normalizedPath = link.path.map((segment) => String(segment)).join('/');
+ if (normalizedPath.includes('/workspace/sources')) {
+ return 'playlist_play';
+ }
+
+ return link.icon;
+ }
+
+ isActive(link: PortalRailLink): boolean {
+ const selectedSection = this.selectedSection();
+ if (selectedSection && link.section) {
+ return selectedSection === link.section;
+ }
+
+ const commands = link.path.map((segment) => String(segment));
+ const tree = this.router.createUrlTree(commands);
+ const matchOptions: IsActiveMatchOptions = {
+ paths: link.exact ? 'exact' : 'subset',
+ queryParams: 'ignored',
+ fragment: 'ignored',
+ matrixParams: 'ignored',
+ };
+ return this.router.isActive(tree, matchOptions);
+ }
+}
diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.spec.ts b/apps/web/src/app/shared/navigation/portal-rail-links.spec.ts
new file mode 100644
index 000000000..5d89562c8
--- /dev/null
+++ b/apps/web/src/app/shared/navigation/portal-rail-links.spec.ts
@@ -0,0 +1,96 @@
+import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
+import { buildPortalRailLinks } from './portal-rail-links';
+
+describe('buildPortalRailLinks', () => {
+ it('builds legacy Xtream links with downloads on Electron', () => {
+ const links = buildPortalRailLinks({
+ provider: 'xtreams',
+ playlistId: 'xtream-1',
+ isElectron: true,
+ workspace: false,
+ });
+
+ expect(links.primary.map((link) => link.section)).toEqual([
+ 'vod',
+ 'live',
+ 'series',
+ ]);
+ expect(links.secondary.map((link) => link.section)).toEqual([
+ 'recently-added',
+ 'search',
+ 'recent',
+ 'favorites',
+ 'downloads',
+ ]);
+ expect(links.primary[0]?.path).toEqual(['/xtreams', 'xtream-1', 'vod']);
+ });
+
+ it('builds workspace Xtream links without downloads on web', () => {
+ const links = buildPortalRailLinks({
+ provider: 'xtreams',
+ playlistId: 'xtream-web',
+ isElectron: false,
+ workspace: true,
+ });
+
+ expect(links.primary).toEqual([
+ {
+ icon: 'movie',
+ tooltip: 'Xtream library',
+ path: ['/workspace', 'xtreams', 'xtream-web'],
+ exact: true,
+ section: 'library',
+ },
+ ]);
+ expect(links.secondary).toEqual([]);
+ });
+
+ it('builds Stalker links with mapped itv section', () => {
+ const links = buildPortalRailLinks({
+ provider: 'stalker',
+ playlistId: 'portal-1',
+ isElectron: false,
+ workspace: false,
+ });
+
+ expect(links.primary.map((link) => link.section)).toEqual([
+ 'vod',
+ 'itv',
+ 'series',
+ ]);
+ expect(links.secondary.map((link) => link.section)).toEqual([
+ 'search',
+ 'recent',
+ 'favorites',
+ ]);
+ expect(links.primary[1]?.path).toEqual(['/stalker', 'portal-1', 'itv']);
+ });
+
+ it('builds playlist links with global favorites in workspace', () => {
+ const links = buildPortalRailLinks({
+ provider: 'playlists',
+ playlistId: 'm3u-1',
+ isElectron: true,
+ workspace: true,
+ });
+
+ expect(links.primary).toEqual([
+ {
+ icon: 'play_circle',
+ tooltip: 'Player',
+ path: ['/workspace', 'playlists', 'm3u-1'],
+ exact: true,
+ section: 'player',
+ },
+ ]);
+
+ expect(links.secondary).toEqual([
+ {
+ icon: 'favorite',
+ tooltip: 'Global favorites',
+ path: ['/workspace', 'playlists', GLOBAL_FAVORITES_PLAYLIST_ID],
+ section: 'favorites',
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/app/shared/navigation/portal-rail-links.ts b/apps/web/src/app/shared/navigation/portal-rail-links.ts
new file mode 100644
index 000000000..626532561
--- /dev/null
+++ b/apps/web/src/app/shared/navigation/portal-rail-links.ts
@@ -0,0 +1,188 @@
+import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
+
+export type PortalProvider = 'xtreams' | 'stalker' | 'playlists';
+
+export interface PortalRailLink {
+ icon: string;
+ tooltip: string;
+ path: (string | number)[];
+ exact?: boolean;
+ section?: string;
+}
+
+interface BuildPortalRailLinksOptions {
+ provider: PortalProvider;
+ playlistId: string;
+ isElectron: boolean;
+ workspace: boolean;
+}
+
+interface PortalRailLinkGroups {
+ primary: PortalRailLink[];
+ secondary: PortalRailLink[];
+}
+
+export function buildPortalRailLinks(
+ options: BuildPortalRailLinksOptions
+): PortalRailLinkGroups {
+ const { provider, playlistId, isElectron, workspace } = options;
+ const root = workspace
+ ? ['/workspace', provider, playlistId]
+ : [`/${provider}`, playlistId];
+
+ if (provider === 'xtreams') {
+ const primary: PortalRailLink[] = [];
+ const secondary: PortalRailLink[] = [];
+
+ if (workspace && !isElectron) {
+ primary.push({
+ icon: 'movie',
+ tooltip: 'Xtream library',
+ path: root,
+ exact: true,
+ section: 'library',
+ });
+ return { primary, secondary };
+ }
+
+ primary.push(
+ {
+ icon: 'movie',
+ tooltip: 'Movies',
+ path: [...root, 'vod'],
+ section: 'vod',
+ },
+ {
+ icon: 'live_tv',
+ tooltip: 'Live TV',
+ path: [...root, 'live'],
+ section: 'live',
+ },
+ {
+ icon: 'tv',
+ tooltip: 'Series',
+ path: [...root, 'series'],
+ section: 'series',
+ }
+ );
+
+ secondary.push(
+ {
+ icon: 'new_releases',
+ tooltip: 'Recently added',
+ path: [...root, 'recently-added'],
+ section: 'recently-added',
+ },
+ {
+ icon: 'search',
+ tooltip: 'Search',
+ path: [...root, 'search'],
+ section: 'search',
+ },
+ {
+ icon: 'history',
+ tooltip: 'Recently viewed',
+ path: [...root, 'recent'],
+ section: 'recent',
+ },
+ {
+ icon: 'favorite',
+ tooltip: 'Favorites',
+ path: [...root, 'favorites'],
+ section: 'favorites',
+ }
+ );
+
+ if (isElectron) {
+ secondary.push({
+ icon: 'download',
+ tooltip: 'Downloads',
+ path: [...root, 'downloads'],
+ section: 'downloads',
+ });
+ }
+
+ return { primary, secondary };
+ }
+
+ if (provider === 'stalker') {
+ const primary: PortalRailLink[] = [
+ {
+ icon: 'movie',
+ tooltip: 'Movies',
+ path: [...root, 'vod'],
+ section: 'vod',
+ },
+ {
+ icon: 'live_tv',
+ tooltip: 'Live TV',
+ path: [...root, 'itv'],
+ section: 'itv',
+ },
+ {
+ icon: 'tv',
+ tooltip: 'Series',
+ path: [...root, 'series'],
+ section: 'series',
+ },
+ ];
+
+ const secondary: PortalRailLink[] = [
+ {
+ icon: 'search',
+ tooltip: 'Search',
+ path: [...root, 'search'],
+ section: 'search',
+ },
+ {
+ icon: 'history',
+ tooltip: 'Recently viewed',
+ path: [...root, 'recent'],
+ section: 'recent',
+ },
+ {
+ icon: 'favorite',
+ tooltip: 'Favorites',
+ path: [...root, 'favorites'],
+ section: 'favorites',
+ },
+ ];
+
+ if (isElectron) {
+ secondary.push({
+ icon: 'download',
+ tooltip: 'Downloads',
+ path: [...root, 'downloads'],
+ section: 'downloads',
+ });
+ }
+
+ return { primary, secondary };
+ }
+
+ return {
+ primary: [
+ {
+ icon: 'play_circle',
+ tooltip: 'Player',
+ path: root,
+ exact: true,
+ section: 'player',
+ },
+ ],
+ secondary: workspace
+ ? [
+ {
+ icon: 'favorite',
+ tooltip: 'Global favorites',
+ path: [
+ '/workspace',
+ 'playlists',
+ GLOBAL_FAVORITES_PLAYLIST_ID,
+ ],
+ section: 'favorites',
+ },
+ ]
+ : [],
+ };
+}