A native PS3 homebrew Jellyfin client written in C/C++ using PSL1GHT, targeting Evilnat CFW or HEN.
Goal: consumer-quality media playback on PS3, the second-best media player on the platform, modelled architecturally on Movian.
- XMB-style UI with animated wave background
- Card-grid browsing (3x3 landscape cards, title/info under the selected card) on all library tabs; search and settings keep the compact row UI
- Browse Movies, TV Shows, and Collections libraries
- Continue Watching tab: server resume list, watched-progress bars on thumbnails, playback resumes at the saved position
- Playback progress reported to the server every 10s (Sessions/Playing + Progress + Stopped), so PS3 viewing updates Continue Watching everywhere
- TV show browser, Series then Seasons then Episodes
- Collections browser, Collection then Movies
- Live search across your Jellyfin library (fires on every keystroke)
- Custom on-screen keyboard for login and search
- Item info overlay (overview, rating, genres, studios, video and audio stream info)
- Thumbnail cache to keep browsing responsive
- Open Sans and Material Icons font rendering via stb_truetype
- Hardware H.264 decode via PS3 VDEC (SPU-accelerated)
- Movian-style temporal frame blending, smooth 60fps display loop with crossfade between decoded 24fps frames
- Bresenham 2:3 pulldown, locked to hardware vsync via gcmSetVBlankHandler
- AV sync locked to within plus or minus 5ms using audio PTS clock plus EMA smoothing
- MP3 audio decode via minimp3 with PCM ring buffer
- Interleaved stereo DMA audio output at 48kHz
- Double-buffered RSX GPU blit via custom vertex and fragment shaders
- In-player HUD: one compact strip with a focusable control row (rewind, play/pause, fast-forward, AUDIO, CC), seek bar, and elapsed/remaining times
- Audio track and subtitle selection via popup menus (Jellyfin AudioStreamIndex / SubtitleStreamIndex); subtitles are burned in server-side (SubtitleMethod=Encode) since the PS3 has no subtitle renderer
- Title overlay in the top-left while paused
- Seek: R2/L2 tap skips 10s (rapid taps batch into one jump), hold pauses and scrubs the bar in 25s steps with a single reposition on release
- Every reposition (seek or track change) stops the server transcode, mints a fresh PlaySessionId via PlaybackInfo, and re-requests stream.ts at the target StartTimeTicks, with full decoder, audio, and jitter-buffer flush
- Jellyfin PlaybackInfo POST with PS3 H.264 transcode profile (720p)
- .pkg packaging via PSL1GHT built-in ppu_rules flow (APPID JFPS30000)
- Crash log written synchronously to /dev_hdd0/tmp/crash_log.txt
- Async ring-buffer logging system (player log at /dev_hdd0/tmp/player_log.txt)
- PS3 with Evilnat CFW or HEN (CEX)
- PSL1GHT toolchain (ppu-gcc at /usr/local/ps3dev/ppu/bin/)
- Jellyfin server reachable from your PS3 (local network recommended; port-forwarded remote also works)
- sfo.xml at ~/ps3dev/ps3py/sfo.xml (used by ppu_rules for the .pkg target)
# Build SELF only
make clean && make
# Build installable PKG
make pkgOutput: JellyFin---PS3.self and JellyFin---PS3.pkg
Transfer the SELF to your PS3 via FTP or USB and launch through webMAN or multiMAN, or install the PKG directly.
| Button | Action |
|---|---|
| X | Select / confirm |
| O | Back |
| D-pad | Navigate |
| L1 / R1 | Cycle tabs (prev/next page in the season browser) |
| Triangle | Item info overlay |
Press any button during playback to bring up the HUD. It auto-hides after 4 seconds while playing and stays up while paused.
| Button | Action |
|---|---|
| Start | Stop / exit player |
| Left / Right | Move focus across the control row (Rew · Play/Pause · FF · AUDIO · CC) |
| X | Activate the focused control (the reveal press is swallowed) |
| X on Rew / FF | Seek -10s / +10s |
| X on AUDIO / CC | Open the audio / subtitle track popup menu |
| R2 / L2 (tap) | Skip +10s / -10s (taps within 1s batch into one seek) |
| R2 / L2 (hold) | Pause and scrub the seek bar; the seek fires once on release |
Track menu: D-pad Up/Down to highlight, X to select, O to close. The active entry carries an accent dot; the CC menu has an Off entry at the top, and an active subtitle underlines the CC button. Picking a different track reopens the stream at the current position with the new track applied (subtitles can take a while to start the first time — the server extracts the track before the burn-in transcode begins).
Seeking, skipping, and track changes all use the same path: the player re-requests the transcode stream at the new position, flushes the decoder, audio, and jitter buffer, then resumes. The seek bar position follows the audio PTS clock (offset by the seek base), so it tracks the real playback time after a seek.
| Button | Action |
|---|---|
| D-pad | Move cursor on keyboard / in results |
| X | Type character / play result |
| Triangle | Toggle caps lock |
| O / CLEAR | Reset search, return to keyboard |
| Down | Jump from keyboard to results |
| Up | Jump from first result back to keyboard |
HTTP stream (MPEG-TS)
|
v
Decode thread
stream_read() -> 188-byte TS packets
|
v
video_feed_ts()
TS demuxer -> PAT/PMT -> PES reassembly
|
+- Audio PES -> adec_push_pes() -> minimp3 -> PCM ring buffer
|
+- Video PES -> vdecDecodeAu() -> VDEC (SPU H.264)
|
v
VDEC callback
vdec_pull_frame() -> YUV -> ARGB
|
v
Jitter buffer (16 slots, ~28 MB at 720p)
PTS + per-frame duration stored per slot
|
v
Upload thread (priority 850)
memcpy -> RSX-local texture (double-buffered)
Also uploads frame B for blend
|
v
Display loop (Movian-style, 60fps)
timing_flip_due() <- Bresenham accumulator
gcmSetVBlankHandler <- hardware vsync at 59.94 Hz
|
v
RSX GPU blit
Crossfade shader blends frame A -> B mid-pulldown
rsxSync() -> flip()
FPS detection: VDEC frame_rate_code maps to exact fractional fps (ISO 13818-2). Display refresh rate queried via videoGetState, 59.94 Hz detected and used for the Bresenham accumulator.
Temporal blending: Each decoded frame carries a remaining duration (us). The display loop consumes vblank periods from that budget. When a frame's remaining duration falls below one vblank period and the next frame is available, the shader crossfades A to B based on the fractional time remaining, eliminating judder on 24fps content without a fixed pulldown pattern.
AV sync: avsync_compute_diff() computes video PTS minus audio PTS. An EMA smooths it over time; avsync_biased_period() nudges each vblank period plus or minus 5000 us to correct drift. Locked = absolute EMA below 41 667 us (about 1 frame at 24fps).
Seek, rewind, and skip are modelled on Movian's flush-and-reposition path, adapted to a single-socket Jellyfin transcode (which is not byte-seekable).
User seeks (Left/Right, L2/R2, or Rew/FF button)
|
v
Compute target = audio_clock + delta, clamp >= 0
Convert to Jellyfin 100-ns ticks (StartTimeTicks)
|
v
Stop decode thread (dec_run flag + join)
Audio and upload threads keep running, idle on empty buffers
|
v
Flush (mirrors Movian mp_flush):
vdec_flush() end + restart VDEC sequence, drop reference frames
adec_flush() empty PES queue + PCM ring, invalidate PTS cursors
jbuf_clear() drop all buffered decoded frames
video_reset_demux() re-acquire PAT/PMT for the new segment
avsync_reset() forget the smoothed AV diff
|
v
Re-request stream at new offset (StartTimeTicks)
Re-prefill jitter buffer
|
v
Respawn decode thread on the new socket
Resume (audio clock re-seeds from the new segment's first PTS)
The audio clock drives the seek bar, so the bar jumps to the new position automatically once the first PTS of the new segment is decoded. A SEEK_REOPEN_VDEC compile define switches the decoder reset from sequence end/restart to a full vdec_close() + vdec_open() rebuild, a slower but bulletproof fallback for A/B testing on hardware.
The in-player HUD draws a darkened strip along the bottom of the screen behind the controls. Getting this right was non-trivial on RSX:
- The first approach drew the strip as a GPU quad using vertex arrays (rsxBindVertexArrayAttrib + rsxDrawVertexArray). This hard-froze the console when paused, because the GPU stalled fetching a stale TEX0 attribute array left bound by the video path.
- The second approach blended the strip on the PPU directly into the framebuffer. It never froze, but color_buffer is CPU-writable RSX VRAM, and per-pixel read-modify-write over the bus pushed frame time from about 16.7ms to about 183ms (around 5fps) whenever the HUD was visible.
- The current approach matches how Movian draws its overlays: a GPU quad with vertices submitted inline into the command FIFO (rsxDrawVertexBegin / rsxDrawVertex4f / rsxDrawVertexEnd). Inline submission has no vertex-array fetch, so it cannot wedge on a stale binding, and it stays on the GPU, so frame time returns to one vblank.
Three implementations are kept behind compile defines so the fast path can be swapped instantly if needed:
| Define | Path |
|---|---|
| (none, default) | Inline GPU quad, fast and freeze-proof |
| HUD_DIM_CPU | CPU pixel blend, slow but bulletproof fallback |
| HUD_DIM_GPU_ARRAY | Original array-fetch quad, known to freeze, test only |
A single rsxSync() fences the prior video draw before the HUD reprograms RSX state, which is also required to avoid a paused-state collision with in-flight video commands.
Audio PES packets (MP3, type 0x03)
|
v
adec_push_pes()
PES header stripped (9 + buf[8] bytes)
|
v
minimp3 decode loop
mp3dec_decode_frame() -> 1152 samples/frame
short PCM -> float32, interleaved L/R
|
v
PCM ring buffer (8192 sample-pairs, ~170ms at 48kHz)
|
v
Audio thread (priority 750)
sysEventQueueReceive() -> DMA event
Blocks up to 100ms waiting for 256 samples
|
v
PS3 audio DMA (8 blocks x 256 samples, 48kHz)
Interleaved layout: L0 R0 L1 R1 ... (per SDK spec)
AV clock: audio_get_clock_us() returns PTS-based time once the first PES with a valid PTS is decoded; falls back to the DMA block counter at startup. After a seek the PTS cursor is invalidated, so the clock re-seeds from the new segment.
| Thread | Priority | Role |
|---|---|---|
| Display (main) | default | Bresenham gate, RSX blit, flip, input poll, seek control |
| Decode | 800 | TS demux, VDEC submit, jitter buffer fill |
| Upload | 850 | memcpy jitter buffer to RSX texture (A + B) |
| Audio | 750 | DMA event loop, PCM ring drain |
| Async log | default | Ring-buffer drain to player_log.txt |
On seek, only the decode thread is stopped and respawned; the audio and upload threads stay alive and idle on empty buffers.
Source is organised into subdirectories by domain.
JellyFin---PS3/
|-- Makefile
|-- ICON0.PNG
`-- source/
|-- main.cpp # Entry point, synchronous crash_log
|-- api/
| |-- jellyfin_api.cpp/h # Jellyfin REST API surface and shared state
| |-- api_auth.cpp # Login / authentication
| |-- api_browse.cpp # Libraries, items, seasons, episodes, search
| |-- api_detail.cpp # Item detail, PlaybackInfo, transcode URL
| `-- api_playstate.cpp # Playback progress reporting (Continue Watching)
|-- audio/
| |-- audio.cpp/h # Audio port, DMA ring buffer, audio thread, PTS clock
| |-- adec.cpp/h # MP3 decode via minimp3, PCM ring buffer, adec_flush
| `-- minimp3.h # Embedded MP3 decoder
|-- cache/
| `-- thumbnail_cache.cpp/h # Thumbnail caching for browse views
|-- gfx/
| |-- rsxutil.cpp/h # RSX helpers, shader blit, framebuffer access
| |-- bitmap.cpp/h # Image loading
| |-- video_shaders.h # Video vertex/fragment ucode (YUV to ARGB + crossfade)
| |-- hud_dim_shaders.h # HUD dim-quad vertex/fragment ucode
| |-- hud_dim_vp.vasm # HUD dim vertex program source
| |-- hud_dim_fp.vasm # HUD dim fragment program source
| |-- wave_shaders.h # Wave background shader ucode
| |-- stb_truetype.h # TTF rasterizer
| |-- stb_image.h # Image decoder
| |-- opensans_regular.h # Open Sans Regular (embedded)
| |-- opensans_bold.h # Open Sans Bold (embedded)
| `-- font8x8.xpm # Fallback bitmap font
|-- net/
| `-- http.cpp/h # HTTP client
|-- player/
| |-- player.h # Public entry point (show_player)
| |-- player_hud.h # Public HUD API (actions, draw, menus)
| |-- player_internal.h # PlayerState, thread contexts, internal API
| |-- core/
| | |-- player.cpp # Orchestrator: session setup, display loop, teardown
| | |-- player_session.cpp # Stream URL builder, error screen, prefill
| | |-- player_menu.cpp # AUDIO / CC track popup handling
| | |-- player_seek.cpp # R2/L2 tap/hold machine + flush-and-reopen seek
| | `-- player_display.cpp # Blend gate, frame swap, HUD overlay, diagnostics
| |-- hud/
| | |-- hud_core.cpp # HUD state, input, focus navigation, public API
| | |-- hud_dim.cpp # Dim quad (inline GPU / CPU / array-fetch paths)
| | `-- hud_draw.cpp # Seek bar, transport row, popup menu rendering
| |-- gpu/
| | |-- player_gpu.cpp # RSX buffer alloc/free, vid_gpu_draw wrapper
| | `-- player_rsx.cpp/h # RSX frame draw (blit + crossfade)
| |-- threads/
| | `-- player_threads.cpp # Decode / upload / audio thread bodies + spawn
| `-- stream/
| `-- stream.cpp/h # HTTP MPEG-TS reader (chunked transfer, TS ring)
|-- ui/
| |-- ui.cpp/h # UI lifecycle (init / RSX state restore / cleanup)
| |-- ui_visuals.h # Shared XMB layout constants, state externs, draw API
| |-- ui_internal.h # Cross-file declarations internal to the UI module
| |-- input/
| | `-- ui_input.cpp # Multi-pad polling, edge detection, nav auto-repeat
| |-- osk/
| | `-- ui_osk_login.cpp # Login on-screen keyboard (get_input)
| |-- xmb/
| | |-- ui_xmb.cpp # XMB main loop (input dispatch + draw phases)
| | |-- ui_xmb_state.cpp # Tab/item/navigation globals
| | |-- ui_nav.cpp # Tab switching + browse input handlers
| | |-- ui_fetch.cpp # Library fetch + sliding-window pagination
| | |-- ui_search.cpp # Search tab input + live search
| | |-- ui_info.cpp # Triangle item info overlay
| | `-- ui_json.cpp # JSON parsing helpers (parse_xmb_items)
| |-- render/
| | |-- ui_text.cpp # Fonts: TTF/icons/iconic rendering, prewarm
| | |-- ui_draw.cpp # Primitives: clears, CPU rects, unicode decode
| | |-- ui_widgets.cpp # Tab bar, jump bar, hints bar, topbar L1/R1
| | |-- ui_lists.cpp # Item/sub-list rows, thumbnails, meta line
| | |-- ui_osk_draw.cpp # Search OSK + results rendering
| | |-- ui_settings.cpp # Settings tab rendering
| | `-- ui_wave.cpp/h # Animated wave background (RSX)
| `-- fonts/
| |-- material_icons.h # Material Icons (embedded)
| `-- iconic_psx.h # PSX-style iconography
|-- util/
| |-- timing.cpp/h # Frame pacing, Bresenham accumulator, AV sync EMA
| `-- plog.cpp/h # Async ring-buffer logging
`-- video/
`-- video.cpp/h # VDEC init, H.264 decode, jitter buffer, fps detection, flush
| Feature | Status |
|---|---|
| Login / Auth | Working |
| Movie browsing | Working |
| TV show browsing | Working (Series, Seasons, Episodes) |
| Collections browsing | Working |
| Continue Watching | Working (resume tab, thumbnail progress bars, resume + reporting) |
| Search | Working (live, keystroke-driven) |
| Item info overlay | Working (overview, rating, genres, studios, video/audio info) |
| Thumbnail cache | Working |
| Video playback | Working (720p H.264, Movian-style 60fps display loop) |
| Temporal frame blending | Working (crossfade shader, eliminates 2:3 judder) |
| Audio playback | Working (48kHz stereo MP3, zero silence blocks) |
| AV sync | Locked (plus or minus 5ms via PTS clock + EMA bias) |
| HUD overlay | Working (inline GPU dim quad, no freeze, full-speed) |
| Seek / rewind / skip | Working (tap-to-skip + hold-to-scrub, StartTimeTicks re-request + full pipeline flush) |
| Audio / subtitle tracks | Working (popup menus; subtitles burned in server-side) |
| PlaybackInfo / transcode | Working (H.264 720p profile, PlaySessionId extracted) |
| PKG packaging | Working (make pkg, APPID JFPS30000) |
| Music library | Not implemented |
During playback, async log output is written to /dev_hdd0/tmp/player_log.txt. The crash log at /dev_hdd0/tmp/crash_log.txt is written synchronously at key lifecycle checkpoints (including per-step seek and HUD checkpoints) and survives crashes that prevent the async logger from flushing. Reading it from the bottom up pinpoints the exact step that failed on hardware.
MIT