add folders page, hevc 4k support

This commit is contained in:
2026-06-04 23:52:30 +03:00
parent 5814714e6a
commit cc01004b49
7 changed files with 153 additions and 4 deletions
+2
View File
@@ -13,6 +13,7 @@ import HomePage from './pages/HomePage'
import { useNewReleaseNotifications } from './hooks/use-new-releases' import { useNewReleaseNotifications } from './hooks/use-new-releases'
const LibraryPage = lazy(() => import('./pages/LibraryPage')) const LibraryPage = lazy(() => import('./pages/LibraryPage'))
const FoldersPage = lazy(() => import('./pages/FoldersPage'))
const DetailPage = lazy(() => import('./pages/DetailPage')) const DetailPage = lazy(() => import('./pages/DetailPage'))
const MusicPage = lazy(() => import('./pages/MusicPage')) const MusicPage = lazy(() => import('./pages/MusicPage'))
const SearchPage = lazy(() => import('./pages/SearchPage')) const SearchPage = lazy(() => import('./pages/SearchPage'))
@@ -233,6 +234,7 @@ export default function App() {
<Route index element={<PageMotion><LandingRedirect /></PageMotion>} /> <Route index element={<PageMotion><LandingRedirect /></PageMotion>} />
<Route path="movies" element={<PageMotion><LibraryPage type="movies" /></PageMotion>} /> <Route path="movies" element={<PageMotion><LibraryPage type="movies" /></PageMotion>} />
<Route path="shows" element={<PageMotion><LibraryPage type="shows" /></PageMotion>} /> <Route path="shows" element={<PageMotion><LibraryPage type="shows" /></PageMotion>} />
<Route path="folders" element={<PageMotion><FoldersPage /></PageMotion>} />
<Route path="music" element={<PageMotion><MusicPage /></PageMotion>} /> <Route path="music" element={<PageMotion><MusicPage /></PageMotion>} />
<Route path="playlists" element={<PageMotion><PlaylistsPage /></PageMotion>} /> <Route path="playlists" element={<PageMotion><PlaylistsPage /></PageMotion>} />
<Route path="item/:id" element={<PageMotion><DetailPage /></PageMotion>} /> <Route path="item/:id" element={<PageMotion><DetailPage /></PageMotion>} />
+2
View File
@@ -33,6 +33,7 @@ const TOP_LEVEL_PATHS = new Set([
'/', '/',
'/movies', '/movies',
'/shows', '/shows',
'/folders',
'/playlists', '/playlists',
'/music', '/music',
'/search', '/search',
@@ -44,6 +45,7 @@ function pageTitleFor(pathname: string): string {
if (pathname === '/') return 'Home' if (pathname === '/') return 'Home'
if (pathname === '/movies') return 'Movies' if (pathname === '/movies') return 'Movies'
if (pathname === '/shows') return 'TV Shows' if (pathname === '/shows') return 'TV Shows'
if (pathname === '/folders') return 'Folders'
if (pathname === '/playlists') return 'Playlists' if (pathname === '/playlists') return 'Playlists'
if (pathname === '/music') return 'Music' if (pathname === '/music') return 'Music'
if (pathname === '/search') return 'Search' if (pathname === '/search') return 'Search'
+2
View File
@@ -16,6 +16,7 @@ import {
Activity, Activity,
Radio, Radio,
Download, Download,
Folders,
} from '../../lib/icons' } from '../../lib/icons'
import type { AuthState } from '../../api/types' import type { AuthState } from '../../api/types'
import AppHeader from './AppHeader' import AppHeader from './AppHeader'
@@ -54,6 +55,7 @@ const NAV_SECTIONS: NavSection[] = [
{ to: '/', icon: Home, label: 'Home' }, { to: '/', icon: Home, label: 'Home' },
{ to: '/movies', icon: Film, label: 'Movies' }, { to: '/movies', icon: Film, label: 'Movies' },
{ to: '/shows', icon: Tv, label: 'Shows' }, { to: '/shows', icon: Tv, label: 'Shows' },
{ to: '/folders', icon: Folders, label: 'Folders' },
{ to: '/playlists', icon: Playlists, label: 'Playlists' }, { to: '/playlists', icon: Playlists, label: 'Playlists' },
{ to: '/live', icon: Radio, label: 'Live TV' }, { to: '/live', icon: Radio, label: 'Live TV' },
{ to: '/requests', icon: Database, label: 'Requests' }, { to: '/requests', icon: Database, label: 'Requests' },
+2 -1
View File
@@ -98,6 +98,7 @@ export function useLibraryItems(
limit?: number limit?: number
enabled?: boolean enabled?: boolean
includePeople?: boolean includePeople?: boolean
recursive?: boolean
}, },
) { ) {
const api = useApi() const api = useApi()
@@ -118,7 +119,7 @@ export function useLibraryItems(
minCommunityRating: opts?.minCommunityRating, minCommunityRating: opts?.minCommunityRating,
startIndex: opts?.startIndex, startIndex: opts?.startIndex,
limit: opts?.limit || 100, limit: opts?.limit || 100,
recursive: true, recursive: opts?.recursive ?? true,
fields: [ fields: [
'PrimaryImageAspectRatio', 'PrimaryImageAspectRatio',
'Overview', 'Overview',
+8 -3
View File
@@ -56,9 +56,14 @@ async function canDecodeHdr(contentType: string, transferFunction: 'pq' | 'hlg'
} }
function supportedVideoCodecs(): string[] { function supportedVideoCodecs(): string[] {
const codecs: string[] = ['h264'] // Universally supported in MSE const codecs: string[] = ['h264']
if (canPlayInMse('video/mp4; codecs="hev1.1.6.L93.B0"') || // HEVC main profile level 5.1 - the spec ceiling for 4K consumer
canPlayInMse('video/mp4; codecs="hvc1.1.6.L93.B0"')) { // hardware. Bumping the probe to 5.1 makes 4K HEVC SDR/HDR content
// direct-play on Chromium + WebView2 when the OS has the HEVC codec
// installed. Older 3.0 probes rejected perfectly valid 4K rips and
// forced the server to transcode.
if (canPlayInMse('video/mp4; codecs="hev1.1.6.L153.B0"') ||
canPlayInMse('video/mp4; codecs="hvc1.1.6.L153.B0"')) {
codecs.push('hevc') codecs.push('hevc')
} }
if (canPlayInMse('video/mp4; codecs="vp09.00.10.08"')) { if (canPlayInMse('video/mp4; codecs="vp09.00.10.08"')) {
+2
View File
@@ -18,6 +18,8 @@ export {
IconSettings as Settings, IconSettings as Settings,
IconLogout as LogOut, IconLogout as LogOut,
IconLibrary as Library, IconLibrary as Library,
IconFolder as Folder,
IconFolders as Folders,
IconCompass as Compass, IconCompass as Compass,
IconFlame as Flame, IconFlame as Flame,
IconFilter as Filter, IconFilter as Filter,
+135
View File
@@ -0,0 +1,135 @@
import { useNavigate } from 'react-router-dom'
import { useLibraries, useLibraryItems } from '../hooks/use-jellyfin'
import { Folder } from '../lib/icons'
import type { BaseItemDto } from '../api/types'
export default function FoldersPage() {
const { data: libraries, isLoading } = useLibraries()
const navigate = useNavigate()
const userLibraries = (libraries || []).filter(l => l.CollectionType)
return (
<div className="px-7 pt-4 pb-12">
<header className="mb-7">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
Folders
</h1>
<p className="text-text-3 text-[13px] mt-1.5 max-w-prose">
Browse libraries by folder. Each library section below shows the top-level folders Jellyfin scanned; click one to see only its items.
</p>
</header>
{isLoading ? (
<FoldersSkeleton />
) : userLibraries.length === 0 ? (
<p className="text-text-3 text-[13px]">No libraries found.</p>
) : (
<div className="space-y-10">
{userLibraries.map(library => (
<LibrarySection
key={library.Id}
library={library}
onOpen={id => navigate(`/item/${id}`)}
/>
))}
</div>
)}
</div>
)
}
function LibrarySection({
library,
onOpen,
}: {
library: BaseItemDto
onOpen: (id: string) => void
}) {
const { data, isLoading } = useLibraryItems(library.Id, {
recursive: false,
sortBy: ['SortName'],
limit: 50,
})
const subfolders = (data?.Items || []).filter(
(it: BaseItemDto) => it.Id && (it.Type === 'CollectionFolder' || it.IsFolder),
)
return (
<section>
<div className="flex items-baseline gap-3 mb-3">
<h2 className="text-xl font-semibold text-text-1 font-display tracking-tight">
{library.Name}
</h2>
<span className="text-[11.5px] text-text-4 tabular-nums">
{subfolders.length} {subfolders.length === 1 ? 'folder' : 'folders'}
</span>
</div>
{isLoading ? (
<FoldersSkeleton compact />
) : subfolders.length === 0 ? (
<p className="text-text-3 text-[12.5px]">
No subfolders in this library. Use Movies / TV Shows in the sidebar to browse everything.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{subfolders.map(folder => (
<FolderCard
key={folder.Id}
name={folder.Name || 'Folder'}
count={folder.RecursiveItemCount ?? folder.ChildCount ?? 0}
onClick={() => onOpen(folder.Id!)}
/>
))}
</div>
)}
</section>
)
}
function FolderCard({
name,
count,
onClick,
}: {
name: string
count: number
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="text-left rounded-xl border border-border bg-elevated/40 hover:border-accent hover:bg-elevated transition p-4 group focus-ring"
>
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-accent/15 text-accent mb-3 group-hover:bg-accent/25">
<Folder size={20} stroke={1.8} />
</div>
<h3 className="font-semibold text-text-1 truncate" title={name}>
{name}
</h3>
<p className="text-[11.5px] text-text-3 mt-1 tabular-nums">
{count.toLocaleString()} {count === 1 ? 'item' : 'items'}
</p>
</button>
)
}
function FoldersSkeleton({ compact = false }: { compact?: boolean }) {
const count = compact ? 4 : 8
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-elevated/40 p-4">
<div className="skeleton w-10 h-10 rounded-lg mb-3" />
<div className="skeleton h-3.5 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded mt-2" />
</div>
))}
</div>
)
}