diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34a9e01..572fa61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,11 +63,14 @@ jobs: submodules: recursive - name: Install deps + # libgl-dev: needed by projectM's cmake FindOpenGL probe even + # though the running binary uses bgfx/Vulkan. + # cmake: GitHub runners preinstall it, but make it explicit. run: | sudo apt update - sudo apt install -y build-essential pkg-config \ + sudo apt install -y build-essential pkg-config cmake \ libsdl2-dev libsdl2-mixer-dev \ - libvulkan-dev libx11-dev \ + libvulkan-dev libx11-dev libgl-dev \ libmpv-dev libcurl4-openssl-dev - name: Stub Configs/Data/Library @@ -102,11 +105,13 @@ jobs: submodules: recursive - name: Install deps + # libgl-dev: needed by projectM's cmake FindOpenGL probe. + # cmake: GitHub runners preinstall it, but make it explicit. run: | sudo apt update - sudo apt install -y build-essential pkg-config \ + sudo apt install -y build-essential pkg-config cmake \ libsdl2-dev libsdl2-mixer-dev \ - libvulkan-dev libx11-dev \ + libvulkan-dev libx11-dev libgl-dev \ libmpv-dev libcurl4-openssl-dev - name: Stub Configs/Data/Library diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 265edb1..92c14e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,11 +73,14 @@ jobs: submodules: recursive - name: Install deps + # libgl-dev: needed by projectM's cmake FindOpenGL probe even + # though the running binary uses bgfx/Vulkan. + # cmake: GitHub runners preinstall it, but make it explicit. run: | sudo apt update - sudo apt install -y build-essential pkg-config \ + sudo apt install -y build-essential pkg-config cmake \ libsdl2-dev libsdl2-mixer-dev \ - libvulkan-dev libx11-dev \ + libvulkan-dev libx11-dev libgl-dev \ libmpv-dev libcurl4-openssl-dev - name: Stub Configs/Data/Library @@ -119,11 +122,13 @@ jobs: submodules: recursive - name: Install deps + # libgl-dev: needed by projectM's cmake FindOpenGL probe. + # cmake: GitHub runners preinstall it, but make it explicit. run: | sudo apt update - sudo apt install -y build-essential pkg-config \ + sudo apt install -y build-essential pkg-config cmake \ libsdl2-dev libsdl2-mixer-dev \ - libvulkan-dev libx11-dev \ + libvulkan-dev libx11-dev libgl-dev \ libmpv-dev libcurl4-openssl-dev - name: Stub Configs/Data/Library diff --git a/.gitignore b/.gitignore index 4d60511..331f492 100644 --- a/.gitignore +++ b/.gitignore @@ -55,9 +55,9 @@ MediaDB.cache # xiptool extract scratch dirs. Source-of-truth for xap files lives in # xips-source/; Data/Xips// only exists as a pack staging area # (xiptool names .xip after its input folder) or as leftover from -# `xiptool extract`. -Data/Xips/default/ -Data/Xips/default-xbox/ +# `xiptool extract`. Tracked .xip artifacts (Data/Xips/*.xip) still +# commit normally — only the unpacked subdirectories are ignored. +Data/Xips/*/ # bgfx build artifacts (Phase 2 of D3D audit). Submodule sources live # at theseus/third-party/{bx,bimg,bgfx}/; their .build/ output is diff --git a/.gitmodules b/.gitmodules index 434141c..1596a85 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "theseus/third-party/bgfx"] path = theseus/third-party/bgfx url = https://github.com/bkaradzic/bgfx.git +[submodule "theseus/third-party/projectm"] + path = theseus/third-party/projectm + url = https://github.com/projectM-visualizer/projectm.git diff --git a/Data/Xips/default.xip b/Data/Xips/default.xip index 8b3e0d2..a1574c6 100644 Binary files a/Data/Xips/default.xip and b/Data/Xips/default.xip differ diff --git a/Data/Xips/jellyfin.xip b/Data/Xips/jellyfin.xip new file mode 100644 index 0000000..f7dd808 Binary files /dev/null and b/Data/Xips/jellyfin.xip differ diff --git a/Data/Xips/plex.xip b/Data/Xips/plex.xip new file mode 100644 index 0000000..f7dd808 Binary files /dev/null and b/Data/Xips/plex.xip differ diff --git a/Library/Jellyfin/jellyfin_logo.png b/Library/Jellyfin/jellyfin_logo.png new file mode 100644 index 0000000..dbde16c Binary files /dev/null and b/Library/Jellyfin/jellyfin_logo.png differ diff --git a/Library/Plex/plex_logo.png b/Library/Plex/plex_logo.png new file mode 100644 index 0000000..02df48d Binary files /dev/null and b/Library/Plex/plex_logo.png differ diff --git a/build/Makefile b/build/Makefile index 221195c..15e3691 100644 --- a/build/Makefile +++ b/build/Makefile @@ -513,6 +513,21 @@ MSYS2 `pacman -S mingw-w64-x86_64-mpv`) `brew install curl`; Debian/Ubuntu `sudo apt install libcurl4-openssl-dev`; \ MSYS2 `pacman -S mingw-w64-x86_64-curl`) endif + # libprojectM is vendored as a git submodule under + # theseus/third-party/projectm and built into a local prefix by the + # `projectm-deps` target below. projectM-4's .pc file declares an + # `opengl` pkg-config dep that doesn't exist on macOS (GL is a system + # framework), so we use direct -I/-L flags instead. + # + # No header-presence gate: `desktop` has a hard prereq on + # `projectm-deps`, so the prefix is guaranteed populated by the time + # the .cpp compile rule fires. (A previous version gated on a + # parse-time wildcard, which caused first-time `make desktop` runs on + # a clean checkout to silently ship a no-milkdrop binary.) + PROJECTM_PREFIX := $(THESEUS_SRC)/third-party/projectm/install + PROJECTM_CFLAGS := -I$(PROJECTM_PREFIX)/include -DTHESEUS_HAS_MILKDROP=1 + PROJECTM_LDFLAGS := -L$(PROJECTM_PREFIX)/lib -lprojectM-4 -lprojectM-4-playlist \ + -Wl,-rpath,$(PROJECTM_PREFIX)/lib endif CDAUDIO_CFLAGS := $(MPV_CFLAGS) CDAUDIO_LDFLAGS := @@ -556,9 +571,10 @@ DESKTOP_CFLAGS = \ $(MPV_CFLAGS) \ $(CDAUDIO_DEFINES) \ $(CDAUDIO_CFLAGS) \ - $(CURL_CFLAGS) + $(CURL_CFLAGS) \ + $(PROJECTM_CFLAGS) -DESKTOP_LDFLAGS = $(SDL2_LDFLAGS) $(DESKTOP_PLATFORM_LDFLAGS) $(MPV_LDFLAGS) $(CDAUDIO_LDFLAGS) $(CURL_LDFLAGS) -lm +DESKTOP_LDFLAGS = $(SDL2_LDFLAGS) $(DESKTOP_PLATFORM_LDFLAGS) $(MPV_LDFLAGS) $(CDAUDIO_LDFLAGS) $(CURL_LDFLAGS) $(PROJECTM_LDFLAGS) -lm # Toggle: build the desktop with AddressSanitizer to catch UB / UAF # at runtime. Use: `make desktop ASAN=1`. @@ -676,6 +692,7 @@ DESKTOP_SRCS_PLATFORM = \ $(THESEUS_SRC)/desktop/inspector.cpp \ $(THESEUS_SRC)/desktop/menu_bar.cpp \ $(THESEUS_SRC)/desktop/xap_editor.cpp \ + $(THESEUS_SRC)/desktop/skin_editor.cpp \ $(THESEUS_SRC)/desktop/TextEditor.cpp \ $(THESEUS_SRC)/desktop/hdd_browser.cpp \ $(THESEUS_SRC)/desktop/xbox_hdd.cpp \ @@ -686,8 +703,11 @@ DESKTOP_SRCS_PLATFORM = \ $(THESEUS_SRC)/desktop/media_player.cpp \ $(THESEUS_SRC)/desktop/boot_anim.cpp \ $(THESEUS_SRC)/desktop/media_ui.cpp \ + $(THESEUS_SRC)/desktop/milkdrop_window.cpp \ $(THESEUS_SRC)/desktop/tmdb.cpp \ $(THESEUS_SRC)/desktop/http_util.cpp \ + $(THESEUS_SRC)/desktop/plex_client.cpp \ + $(THESEUS_SRC)/desktop/jellyfin_client.cpp \ $(THESEUS_SRC)/desktop/xiso.cpp # ImGui sources. imgui_impl_opengl3 is the GL renderer backend used by @@ -857,9 +877,37 @@ shaders-bgfx-dx11: @echo "=== bgfx shaders compiled to $(SHADERS_OUT_DIR)/dx11/ ===" -desktop: $(DESKTOP_TARGET) +desktop: projectm-deps $(DESKTOP_TARGET) @echo "=== desktop build complete: $(DESKTOP_TARGET) ===" +# Build the vendored libprojectM (4.x) into a local prefix the first time +# it's needed. Absolute path so cmake stores it correctly in its cache and +# `cmake --install` resolves the prefix from the project root, not the +# build subdir. +PROJECTM_SRC := $(abspath $(THESEUS_SRC)/third-party/projectm) +PROJECTM_MARKER := $(PROJECTM_SRC)/install/include/projectM-4/projectM.h + +.PHONY: projectm-deps projectm-clean +projectm-deps: $(PROJECTM_MARKER) + +$(PROJECTM_MARKER): + @if [ ! -f $(PROJECTM_SRC)/CMakeLists.txt ]; then \ + echo "projectM submodule not initialized; running git submodule update..." ; \ + (cd $(THESEUS_SRC)/.. && git submodule update --init --recursive --depth 1 $(PROJECTM_SRC)) ; \ + fi + @echo "=== Building libprojectM 4.x (one-time) ===" + cd $(PROJECTM_SRC) && cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=$(PROJECTM_SRC)/install \ + -DENABLE_PLAYLIST=ON \ + -DENABLE_SDL_UI=OFF \ + -DENABLE_TESTING=OFF + cd $(PROJECTM_SRC) && cmake --build build -j$$(sysctl -n hw.ncpu 2>/dev/null || nproc) + cd $(PROJECTM_SRC) && cmake --install build + +projectm-clean: + rm -rf $(PROJECTM_SRC)/build $(PROJECTM_SRC)/install + $(DESKTOP_TARGET): $(DESKTOP_OBJS) @mkdir -p $(DESKTOP_DIR) $(DESKTOP_CXX) -o $@ $(DESKTOP_OBJS) $(DESKTOP_LDFLAGS) @@ -959,6 +1007,18 @@ MPV_MINGW_LIB = $(MPV_MINGW_DIR) CURL_MINGW_INC = $(CURL_MINGW_DIR)/include CURL_MINGW_LIB = $(CURL_MINGW_DIR)/lib +# libprojectM 4.x for Windows cross. Built by the `projectm-deps-win64` +# target below into a separate install-win64/ prefix (kept apart from +# the native install/ prefix so both can coexist). Unlike the native +# desktop branch, we don't gate on header presence: desktop-win64 has +# a hard prereq on projectm-deps-win64, so the prefix is guaranteed +# populated by the time .cpp compilation fires. -I is silently ignored +# for missing dirs at parse time, so this is safe even on clean checkout. +PROJECTM_WIN64_PREFIX := $(abspath $(THESEUS_SRC)/third-party/projectm/install-win64) +PROJECTM_WIN64_CFLAGS := -I$(PROJECTM_WIN64_PREFIX)/include -DTHESEUS_HAS_MILKDROP=1 +PROJECTM_WIN64_LDFLAGS := -L$(PROJECTM_WIN64_PREFIX)/lib \ + -lprojectM-4 -lprojectM-4-playlist + # DESKTOP_CFLAGS but with the mingw SDL2 / mpv / curl paths instead of # the macOS ones, and SDL_MAIN_HANDLED so SDL2 doesn't try to substitute # its own main(). @@ -985,6 +1045,7 @@ WIN64_CFLAGS = \ -I$(THESEUS_SRC)/shared \ -I$(THESEUS_SRC)/engine \ -I$(THESEUS_SRC)/render \ + $(PROJECTM_WIN64_CFLAGS) \ -Wno-write-strings \ -Wno-deprecated-declarations \ -Wno-reorder \ @@ -1016,8 +1077,9 @@ WIN64_LDFLAGS = \ -lmingw32 -lSDL2main -lSDL2 -lSDL2_mixer \ $(if $(BGFX),,-lglew32) \ -lmpv -lcurl \ - $(if $(BGFX),,-lopengl32) -lgdi32 -limm32 -lole32 -loleaut32 -lsetupapi -lversion -lwinmm -luuid \ - -lws2_32 -ldbghelp -lavrt -ldwmapi + $(PROJECTM_WIN64_LDFLAGS) \ + -lopengl32 -lgdi32 -limm32 -lole32 -loleaut32 -lsetupapi -lversion -lwinmm -luuid \ + -lws2_32 -ldbghelp -lavrt -ldwmapi -lcomdlg32 # BGFX=1 on Windows: link Vulkan via bgfx, drop the OpenGL backend. # bgfx libs were built from the macOS host with: @@ -1059,18 +1121,48 @@ WIN64_OBJS = $(patsubst $(THESEUS_SRC)/%.cpp,$(WIN64_DIR)/%.o,$(WIN64_SRCS)) WIN64_DEPS = $(WIN64_OBJS:.o=.d) -include $(WIN64_DEPS) -.PHONY: desktop-win64 desktop-win64-clean +.PHONY: desktop-win64 desktop-win64-clean projectm-deps-win64 projectm-win64-clean -desktop-win64: $(WIN64_TARGET) +desktop-win64: projectm-deps-win64 $(WIN64_TARGET) @echo "=== windows cross-compile complete: $(WIN64_TARGET) ===" +# Cross-build libprojectM 4.x for Windows via mingw-w64. Uses the cmake +# toolchain file in build/cmake-toolchains/ and installs into a +# Windows-specific prefix so it doesn't collide with the native install +# (which the host's `desktop` target reads). +PROJECTM_WIN64_MARKER := $(PROJECTM_WIN64_PREFIX)/include/projectM-4/projectM.h +PROJECTM_WIN64_TOOLCHAIN := $(abspath $(THESEUS_SRC)/../build/cmake-toolchains/mingw-w64-x86_64.cmake) + +projectm-deps-win64: $(PROJECTM_WIN64_MARKER) + +$(PROJECTM_WIN64_MARKER): + @if [ ! -f $(PROJECTM_SRC)/CMakeLists.txt ]; then \ + echo "projectM submodule not initialized; running git submodule update..." ; \ + (cd $(THESEUS_SRC)/.. && git submodule update --init --recursive --depth 1 $(PROJECTM_SRC)) ; \ + fi + @echo "=== Cross-building libprojectM 4.x for Windows x64 (one-time) ===" + cd $(PROJECTM_SRC) && cmake -S . -B build-win64 \ + -DCMAKE_TOOLCHAIN_FILE=$(PROJECTM_WIN64_TOOLCHAIN) \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=$(PROJECTM_WIN64_PREFIX) \ + -DBUILD_SHARED_LIBS=ON \ + -DENABLE_PLAYLIST=ON \ + -DENABLE_SDL_UI=OFF \ + -DENABLE_TESTING=OFF + cd $(PROJECTM_SRC) && cmake --build build-win64 \ + -j$$(sysctl -n hw.ncpu 2>/dev/null || nproc) + cd $(PROJECTM_SRC) && cmake --install build-win64 + +projectm-win64-clean: + rm -rf $(PROJECTM_SRC)/build-win64 $(PROJECTM_WIN64_PREFIX) + $(WIN64_TARGET): $(WIN64_OBJS) @mkdir -p $(WIN64_DIR) $(WIN64_CXX) -o $@ $(WIN64_OBJS) $(WIN64_LDFLAGS) @# Bundle every DLL the exe needs so the build directory is a self- @# contained Windows drop. -static-libgcc -static-libstdc++ folds the @# core runtime in; SDL2 / SDL2_mixer / libmpv / libcurl / libwinpthread - @# are dynamic and have to ship alongside. + @# / libprojectM are dynamic and have to ship alongside. @cp $(SDL2_MINGW_BIN)/SDL2.dll $(WIN64_DIR)/ @cp $(MIXER_MINGW_BIN)/SDL2_mixer.dll $(WIN64_DIR)/ @cp $(WIN64_PTHREAD_DLL) $(WIN64_DIR)/ @@ -1078,6 +1170,14 @@ $(WIN64_TARGET): $(WIN64_OBJS) @for f in $(CURL_MINGW_DIR)/bin/libcurl*.dll $(CURL_MINGW_DIR)/libcurl*.dll; do \ [ -f "$$f" ] && cp "$$f" $(WIN64_DIR)/ || true ; \ done; true + @# projectM cmake puts shared libs in install/bin on mingw and in + @# install/lib on UNIX-style sysroots; copy from whichever exists. + @for D in $(PROJECTM_WIN64_PREFIX)/bin $(PROJECTM_WIN64_PREFIX)/lib; do \ + [ -d "$$D" ] || continue ; \ + for f in $$D/libprojectM-4*.dll $$D/projectM-4*.dll; do \ + [ -f "$$f" ] && cp "$$f" $(WIN64_DIR)/ || true ; \ + done ; \ + done; true @for D in Configs Data Library; do \ if command -v rsync >/dev/null 2>&1; then \ rsync -a --delete $(THESEUS_SRC)/../$$D/ $(WIN64_DIR)/$$D/; \ @@ -1099,7 +1199,7 @@ $(WIN64_DIR)/%.o: $(THESEUS_SRC)/%.cpp @mkdir -p $(dir $@) $(WIN64_CXX) $(WIN64_CFLAGS) -c -o $@ $< -desktop-win64-clean: +desktop-win64-clean: projectm-win64-clean rm -rf $(WIN64_DIR) # ============================================================ @@ -1146,6 +1246,17 @@ LINUX_ARM64_MPV_LDFLAGS := $(shell $(LINUX_ARM64_PKG_CONFIG) --libs mpv 2>/dev/n LINUX_ARM64_CURL_CFLAGS := $(shell $(LINUX_ARM64_PKG_CONFIG) --cflags libcurl 2>/dev/null) LINUX_ARM64_CURL_LDFLAGS:= $(shell $(LINUX_ARM64_PKG_CONFIG) --libs libcurl 2>/dev/null) +# libprojectM 4.x for the Linux ARM64 cross. Built by the +# `projectm-deps-linux-arm64` target below into install-linux-arm64/. +# CI runs Linux ARM64 on a native ARM runner via the regular `desktop` +# target, so this prefix is only ever populated when someone is +# cross-compiling locally from an x86_64 Linux host. +PROJECTM_LINUX_ARM64_PREFIX := $(abspath $(THESEUS_SRC)/third-party/projectm/install-linux-arm64) +PROJECTM_LINUX_ARM64_CFLAGS := -I$(PROJECTM_LINUX_ARM64_PREFIX)/include -DTHESEUS_HAS_MILKDROP=1 +PROJECTM_LINUX_ARM64_LDFLAGS := -L$(PROJECTM_LINUX_ARM64_PREFIX)/lib \ + -lprojectM-4 -lprojectM-4-playlist \ + '-Wl,-rpath,$$ORIGIN/lib' + LINUX_ARM64_CFLAGS = \ -std=c++17 \ $(if $(DEBUG),-g -O0,-O2 -DNDEBUG) \ @@ -1165,6 +1276,7 @@ LINUX_ARM64_CFLAGS = \ -I$(THESEUS_SRC)/render \ $(LINUX_ARM64_MPV_CFLAGS) \ $(LINUX_ARM64_CURL_CFLAGS) \ + $(PROJECTM_LINUX_ARM64_CFLAGS) \ -Wno-write-strings \ -Wno-deprecated-declarations \ -Wno-reorder \ @@ -1184,6 +1296,7 @@ LINUX_ARM64_LDFLAGS = \ -lGL \ $(LINUX_ARM64_MPV_LDFLAGS) \ $(LINUX_ARM64_CURL_LDFLAGS) \ + $(PROJECTM_LINUX_ARM64_LDFLAGS) \ -lm -ldl -lpthread # Same source list as desktop. the compiler reads the same files. @@ -1191,11 +1304,40 @@ LINUX_ARM64_OBJS = $(patsubst $(THESEUS_SRC)/%.cpp,$(LINUX_ARM64_DIR)/%.o,$(DESK LINUX_ARM64_DEPS = $(LINUX_ARM64_OBJS:.o=.d) -include $(LINUX_ARM64_DEPS) -.PHONY: desktop-linux-arm64 desktop-linux-arm64-clean +.PHONY: desktop-linux-arm64 desktop-linux-arm64-clean projectm-deps-linux-arm64 projectm-linux-arm64-clean -desktop-linux-arm64: $(LINUX_ARM64_TARGET) +desktop-linux-arm64: projectm-deps-linux-arm64 $(LINUX_ARM64_TARGET) @echo "=== linux arm64 cross-compile complete: $(LINUX_ARM64_TARGET) ===" +# Cross-build libprojectM 4.x for Linux ARM64 via aarch64-linux-gnu-{gcc,g++}. +# Same shape as the Win64 path; install prefix kept separate so x86_64 +# host and aarch64 target sysroots don't collide. +PROJECTM_LINUX_ARM64_MARKER := $(PROJECTM_LINUX_ARM64_PREFIX)/include/projectM-4/projectM.h +PROJECTM_LINUX_ARM64_TOOLCHAIN := $(abspath $(THESEUS_SRC)/../build/cmake-toolchains/aarch64-linux-gnu.cmake) + +projectm-deps-linux-arm64: $(PROJECTM_LINUX_ARM64_MARKER) + +$(PROJECTM_LINUX_ARM64_MARKER): + @if [ ! -f $(PROJECTM_SRC)/CMakeLists.txt ]; then \ + echo "projectM submodule not initialized; running git submodule update..." ; \ + (cd $(THESEUS_SRC)/.. && git submodule update --init --recursive --depth 1 $(PROJECTM_SRC)) ; \ + fi + @echo "=== Cross-building libprojectM 4.x for Linux ARM64 (one-time) ===" + cd $(PROJECTM_SRC) && cmake -S . -B build-linux-arm64 \ + -DCMAKE_TOOLCHAIN_FILE=$(PROJECTM_LINUX_ARM64_TOOLCHAIN) \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=$(PROJECTM_LINUX_ARM64_PREFIX) \ + -DBUILD_SHARED_LIBS=ON \ + -DENABLE_PLAYLIST=ON \ + -DENABLE_SDL_UI=OFF \ + -DENABLE_TESTING=OFF + cd $(PROJECTM_SRC) && cmake --build build-linux-arm64 \ + -j$$(sysctl -n hw.ncpu 2>/dev/null || nproc) + cd $(PROJECTM_SRC) && cmake --install build-linux-arm64 + +projectm-linux-arm64-clean: + rm -rf $(PROJECTM_SRC)/build-linux-arm64 $(PROJECTM_LINUX_ARM64_PREFIX) + $(LINUX_ARM64_TARGET): $(LINUX_ARM64_OBJS) @mkdir -p $(LINUX_ARM64_DIR) $(LINUX_ARM64_CXX) -o $@ $(LINUX_ARM64_OBJS) $(LINUX_ARM64_LDFLAGS) @@ -1208,11 +1350,17 @@ $(LINUX_ARM64_TARGET): $(LINUX_ARM64_OBJS) cp -R $(THESEUS_SRC)/../$$D/. $(LINUX_ARM64_DIR)/$$D/; \ fi; \ done - @echo " (synced Configs/Data/Library -> $(LINUX_ARM64_DIR))" + @# Bundle projectM .so files next to the binary under lib/ so the + @# $ORIGIN/lib rpath in PROJECTM_LINUX_ARM64_LDFLAGS resolves at runtime. + @mkdir -p $(LINUX_ARM64_DIR)/lib + @for f in $(PROJECTM_LINUX_ARM64_PREFIX)/lib/libprojectM-4*.so* ; do \ + [ -e "$$f" ] && cp -a "$$f" $(LINUX_ARM64_DIR)/lib/ || true ; \ + done; true + @echo " (synced Configs/Data/Library + projectM .so -> $(LINUX_ARM64_DIR))" $(LINUX_ARM64_DIR)/%.o: $(THESEUS_SRC)/%.cpp @mkdir -p $(dir $@) $(LINUX_ARM64_CXX) $(LINUX_ARM64_CFLAGS) -c -o $@ $< -desktop-linux-arm64-clean: +desktop-linux-arm64-clean: projectm-linux-arm64-clean rm -rf $(LINUX_ARM64_DIR) diff --git a/build/cmake-toolchains/aarch64-linux-gnu.cmake b/build/cmake-toolchains/aarch64-linux-gnu.cmake new file mode 100644 index 0000000..f54ef25 --- /dev/null +++ b/build/cmake-toolchains/aarch64-linux-gnu.cmake @@ -0,0 +1,35 @@ +# Cross-compile toolchain for aarch64-linux-gnu (Linux ARM64) from an +# x86_64 Linux host. Used by the `projectm-deps-linux-arm64` Makefile +# target to build libprojectM 4.x into +# theseus/third-party/projectm/install-linux-arm64/. +# +# Pass to cmake with: +# -DCMAKE_TOOLCHAIN_FILE=/path/to/aarch64-linux-gnu.cmake +# +# Override the compiler triple at configure time if your toolchain uses +# a different prefix: +# -DAARCH64_TRIPLE=aarch64-linux-gnu +# +# CI runs Linux ARM64 on a native ARM runner, so this file is only used +# for local cross builds from x86_64 hosts. + +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR aarch64) + +if(NOT DEFINED AARCH64_TRIPLE) + set(AARCH64_TRIPLE aarch64-linux-gnu) +endif() + +set(CMAKE_C_COMPILER ${AARCH64_TRIPLE}-gcc) +set(CMAKE_CXX_COMPILER ${AARCH64_TRIPLE}-g++) +set(CMAKE_AR ${AARCH64_TRIPLE}-ar) +set(CMAKE_RANLIB ${AARCH64_TRIPLE}-ranlib) + +# Debian multiarch puts aarch64 sysroot libs under /usr/lib/aarch64-linux-gnu +# and shared headers at /usr/include. Point cmake at the multiarch lib dir +# so find_library() picks the arm64 .so files when both arches are present. +set(CMAKE_FIND_ROOT_PATH /usr/${AARCH64_TRIPLE} /usr/lib/${AARCH64_TRIPLE}) +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH) diff --git a/build/cmake-toolchains/mingw-w64-x86_64.cmake b/build/cmake-toolchains/mingw-w64-x86_64.cmake new file mode 100644 index 0000000..28380e6 --- /dev/null +++ b/build/cmake-toolchains/mingw-w64-x86_64.cmake @@ -0,0 +1,33 @@ +# Cross-compile toolchain for x86_64-w64-mingw32 (Windows x64) from a +# POSIX host (macOS Homebrew mingw-w64, or Linux apt mingw-w64). Used +# by the `projectm-deps-win64` Makefile target to build libprojectM 4.x +# into theseus/third-party/projectm/install-win64/. +# +# Pass to cmake with: +# -DCMAKE_TOOLCHAIN_FILE=/path/to/mingw-w64-x86_64.cmake +# +# Override the compiler prefix at configure time if your toolchain uses +# a different triple: +# -DMINGW_TRIPLE=x86_64-w64-mingw32 + +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) + +if(NOT DEFINED MINGW_TRIPLE) + set(MINGW_TRIPLE x86_64-w64-mingw32) +endif() + +set(CMAKE_C_COMPILER ${MINGW_TRIPLE}-gcc) +set(CMAKE_CXX_COMPILER ${MINGW_TRIPLE}-g++) +set(CMAKE_RC_COMPILER ${MINGW_TRIPLE}-windres) +set(CMAKE_AR ${MINGW_TRIPLE}-ar) +set(CMAKE_RANLIB ${MINGW_TRIPLE}-ranlib) + +# Look for headers/libs in the mingw sysroot, not the host system. The +# sysroot path varies by distro; cmake walks the compiler's default +# search path so an explicit CMAKE_FIND_ROOT_PATH usually isn't needed, +# but locking the find-modes prevents accidental host-lib pickups. +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/theseus/desktop/audio_sdl.cpp b/theseus/desktop/audio_sdl.cpp index 1dc46e5..c880cc4 100644 --- a/theseus/desktop/audio_sdl.cpp +++ b/theseus/desktop/audio_sdl.cpp @@ -351,6 +351,17 @@ static std::string StripExtension(const char* name) // --------------------------------------------------------------------------- // Init / Shutdown // --------------------------------------------------------------------------- +static bool s_muted = false; +extern float g_masterVolume; + +static int MasterScaled(int v) +{ + int s = (int)(v * g_masterVolume); + if (s < 0) s = 0; + if (s > MIX_MAX_VOLUME) s = MIX_MAX_VOLUME; + return s; +} + int DashAudio_Init(void) { if (s_initialized) return 0; @@ -385,6 +396,10 @@ int DashAudio_Init(void) // Honor --muted before XAP initialize() fires Audio nodes during InitApp. extern bool g_audioMuted; if (g_audioMuted) DashAudio_MuteAll(); + else { + Mix_Volume(-1, MasterScaled(MIX_MAX_VOLUME)); + Mix_VolumeMusic(MasterScaled(MIX_MAX_VOLUME)); + } // Auto-scan music collection. Root is configurable via desktop.ini @@ -406,8 +421,6 @@ const char* DashMusic_GetConfiguredRoot(void) return "Data/Music"; } -static bool s_muted = false; - void DashAudio_MuteAll(void) { if (!s_initialized) return; @@ -420,8 +433,18 @@ void DashAudio_UnmuteAll(void) { if (!s_initialized) return; s_muted = false; - Mix_Volume(-1, MIX_MAX_VOLUME); // Restore all channels - Mix_VolumeMusic(MIX_MAX_VOLUME); // Restore music + Mix_Volume(-1, MasterScaled(MIX_MAX_VOLUME)); + Mix_VolumeMusic(MasterScaled(MIX_MAX_VOLUME)); +} + +extern "C" void DashAudio_SetMasterVolume(float vol) +{ + if (vol < 0.0f) vol = 0.0f; + if (vol > 1.0f) vol = 1.0f; + g_masterVolume = vol; + if (!s_initialized || s_muted) return; + Mix_Volume(-1, MasterScaled(MIX_MAX_VOLUME)); + Mix_VolumeMusic(MasterScaled(MIX_MAX_VOLUME)); } void DashAudio_Shutdown(void) @@ -542,7 +565,7 @@ void DashAudio_SetChannelVolume(int channel, float vol) int v = (int)(vol * MIX_MAX_VOLUME); if (v < 0) v = 0; if (v > MIX_MAX_VOLUME) v = MIX_MAX_VOLUME; - Mix_Volume(channel, v); + Mix_Volume(channel, MasterScaled(v)); } void DashAudio_SetChannelPan(int channel, float pan) @@ -649,7 +672,7 @@ void DashAudio_SetMusicVolume(float vol) int v = (int)(vol * MIX_MAX_VOLUME); if (v < 0) v = 0; if (v > MIX_MAX_VOLUME) v = MIX_MAX_VOLUME; - Mix_VolumeMusic(v); + Mix_VolumeMusic(MasterScaled(v)); } double DashAudio_GetMusicPosition(void) diff --git a/theseus/desktop/audio_sdl.h b/theseus/desktop/audio_sdl.h index fe1ee12..04395fd 100644 --- a/theseus/desktop/audio_sdl.h +++ b/theseus/desktop/audio_sdl.h @@ -72,6 +72,9 @@ const char* DashMusic_FormatTime(int totalSeconds); void DashAudio_MuteAll(void); void DashAudio_UnmuteAll(void); +// Master volume (0.0 - 1.0). Composes on top of per-channel and music volumes. +void DashAudio_SetMasterVolume(float vol); + // PCM capture for audio visualizer // Fills outLeft/outRight with the most recent 'count' stereo samples (int16) void DashAudio_GetPCMSamples(int16_t* outLeft, int16_t* outRight, int count); diff --git a/theseus/desktop/d3d8_sdl.h b/theseus/desktop/d3d8_sdl.h index 4343e82..9107826 100644 --- a/theseus/desktop/d3d8_sdl.h +++ b/theseus/desktop/d3d8_sdl.h @@ -2611,14 +2611,20 @@ class IDirect3DDevice8 { if (tex->m_width == 0 || tex->m_height == 0) return; UINT sz = tex->m_width * tex->m_height * 4; if (!bgfx::isValid(tex->m_bgfxTex)) { + // BLIT_DST on Metal selects shared-storage mode that allows + // CPU-side updates via updateTexture2D. Without it bgfx may + // place the texture in private GPU memory and silently drop + // subsequent CPU writes — dynamic textures (audio visualizer, + // any async asset upload) freeze on the first frame. tex->m_bgfxTex = bgfx::createTexture2D( (uint16_t)tex->m_width, (uint16_t)tex->m_height, false, 1, bgfx::TextureFormat::RGBA8, - BGFX_TEXTURE_NONE, - bgfx::copy(tex->m_pixels, sz)); - tex->m_bgfxDirty = false; - } else if (tex->m_bgfxDirty) { + BGFX_TEXTURE_BLIT_DST, + nullptr); + tex->m_bgfxDirty = true; + } + if (bgfx::isValid(tex->m_bgfxTex) && tex->m_bgfxDirty) { bgfx::updateTexture2D(tex->m_bgfxTex, 0, 0, 0, 0, (uint16_t)tex->m_width, (uint16_t)tex->m_height, bgfx::copy(tex->m_pixels, sz)); diff --git a/theseus/desktop/dashapp.cpp b/theseus/desktop/dashapp.cpp index f7e2ac4..e20f698 100755 --- a/theseus/desktop/dashapp.cpp +++ b/theseus/desktop/dashapp.cpp @@ -808,6 +808,11 @@ void CleanupApp() CdAudio_Shutdown(); DashAudio_Shutdown(); + extern void Plex_Shutdown(); + Plex_Shutdown(); + extern void Jellyfin_Shutdown(); + Jellyfin_Shutdown(); + delete[] g_szAppDir; g_szAppDir = NULL; diff --git a/theseus/desktop/desktop_nodes.cpp b/theseus/desktop/desktop_nodes.cpp index fdb9a72..4bce2aa 100644 --- a/theseus/desktop/desktop_nodes.cpp +++ b/theseus/desktop/desktop_nodes.cpp @@ -601,12 +601,13 @@ static void VizSetPixel2(CSurfx* pSurfx, int x, int y, BYTE color) void CAudioVisualizer::RenderDynamicTexture(CSurfx* pSurfx) { - // On desktop, check if source audio clip is playing - CAudioClip* pAudioClip = (CAudioClip*)m_source; - if (pAudioClip == NULL || pAudioClip->GetNodeClass() != NODE_CLASS(CAudioClip) || pAudioClip->m_transportMode != TRANSPORT_PLAY) - return; - - // Desktop: pull PCM from the Mix_SetPostMix ring buffer + // Desktop music playback goes through Mix_PlayMusic (CMP3Pump / + // CMusicCollection), not the CAudioClip API the Xbox path uses, so the + // scene's m_source CAudioClip never enters TRANSPORT_PLAY. The + // Mix_SetPostMix ring captures every mixed channel anyway; just pull + // from it whenever the visualizer ticks. Silence reads as zeros and + // draws a flat scope, which is the right behaviour when nothing is + // playing. DashAudio_GetPCMSamples(m_pcmLeft, m_pcmRight, 256); int nSamples = 256; @@ -4605,6 +4606,906 @@ START_NODE_FUN(CPlaylistCollection, CNode) END_NODE_FUN() +// ============================================================================ +// CPlexLibrary, XAP-callable view over the user's Plex server. Reads from +// the pre-stage cache in plex_client.cpp; multi-server selection is a +// future feature, the first reachable server wins. +// ============================================================================ + +#include "plex_client.h" + +class CPlexLibrary : public CNode +{ +public: + CPlexLibrary() + : m_currentLib(-1), m_currentShow(-1), m_currentSeason(-1) {} + + DECLARE_NODE(CPlexLibrary, CNode) + DECLARE_NODE_PROPS() + DECLARE_NODE_FUNCTIONS() + + int m_currentLib; + int m_currentShow; + int m_currentSeason; + std::string m_serverUri; + + void EnsureLibrariesPending() + { + if (!Plex_HasToken()) return; + Plex_StartSync(); + if (m_serverUri.empty() && Plex_ServersReady()) { + std::vector srv = Plex_GetServers(); + if (!srv.empty()) m_serverUri = srv[0].uri; + } + } + + int GetCount() + { + EnsureLibrariesPending(); + return (int)Plex_Cache_GetLibraries().size(); + } + + CStrObject* GetName(int i) + { + std::vector libs = Plex_Cache_GetLibraries(); + if (i < 0 || i >= (int)libs.size()) return new CStrObject; + return new CStrObject(_T(libs[i].title.c_str())); + } + + CStrObject* GetMeta(int i) + { + std::vector libs = Plex_Cache_GetLibraries(); + if (i < 0 || i >= (int)libs.size()) return new CStrObject; + const char* t = libs[i].type.c_str(); + return new CStrObject(_T(t)); + } + + void SetCurrent(int i) + { + if (i == m_currentLib) return; + m_currentLib = i; + m_currentShow = -1; + m_currentSeason = -1; + } + + CStrObject* GetServerName() + { + EnsureLibrariesPending(); + return new CStrObject(_T(Plex_GetActiveServerName().c_str())); + } + + int IsSyncReady() { EnsureLibrariesPending(); return Plex_SyncReady() ? 1 : 0; } + CStrObject* GetSyncPhase() { return new CStrObject(_T(Plex_SyncPhase().c_str())); } + int GetSyncProgress(){ return Plex_SyncProgress(); } + + std::vector CurrentItems() + { + std::vector libs = Plex_Cache_GetLibraries(); + if (m_currentLib < 0 || m_currentLib >= (int)libs.size()) return {}; + return Plex_Cache_GetItems(libs[m_currentLib].sectionKey); + } + + CStrObject* GetItemType(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return new CStrObject; + return new CStrObject(_T(items[i].type.c_str())); + } + + // Subtitle per view: 1="MOVIE / SHOW (year)", 2="SEASON N", 3="SxEy". + CStrObject* GetSubtitle(int view, int i) + { + if (i < 0) return new CStrObject; + std::string s; + + if (view == 1) { + std::vector items = CurrentItems(); + if (i >= (int)items.size()) return new CStrObject; + const PlexItem& it = items[i]; + s = it.type; + for (auto& c : s) c = (char)toupper((unsigned char)c); + if (!it.year.empty()) { s += " \xE2\x80\xA2 "; s += it.year; } + return new CStrObject(_T(s.c_str())); + } + if (view == 2) { + std::vector seasons; + Plex_Cache_GetSeasons(CurrentShowKey(), seasons); + if (i >= (int)seasons.size()) return new CStrObject; + s = "SEASON "; + s += seasons[i].index; + return new CStrObject(_T(s.c_str())); + } + if (view == 3) { + std::vector eps; + Plex_Cache_GetEpisodes(CurrentSeasonKey(), eps); + if (i >= (int)eps.size()) return new CStrObject; + s = "S"; + std::vector seasons; + Plex_Cache_GetSeasons(CurrentShowKey(), seasons); + if (m_currentSeason >= 0 && m_currentSeason < (int)seasons.size()) + s += seasons[m_currentSeason].index; + if (!eps[i].index.empty()) { s += "E"; s += eps[i].index; } + return new CStrObject(_T(s.c_str())); + } + return new CStrObject; + } + + // Y-button modal text: title + full plot. Seasons inherit the show plot. + CStrObject* GetInfoText(int view, int i) + { + if (i < 0) return new CStrObject; + std::string body; + + if (view == 1) { + std::vector items = CurrentItems(); + if (i >= (int)items.size()) return new CStrObject; + const PlexItem& it = items[i]; + body = it.title; + if (!it.year.empty()) { body += " ("; body += it.year; body += ")"; } + if (!it.summary.empty()) { body += "\n\n"; body += it.summary; } + } + else if (view == 2) { + // Seasons have no summary -- surface the show's plot. + std::vector items = CurrentItems(); + if (m_currentShow < 0 || m_currentShow >= (int)items.size()) return new CStrObject; + const PlexItem& sh = items[m_currentShow]; + std::vector seasons; + Plex_Cache_GetSeasons(CurrentShowKey(), seasons); + body = sh.title; + if (i < (int)seasons.size()) { body += " - "; body += seasons[i].title; } + if (!sh.summary.empty()) { body += "\n\n"; body += sh.summary; } + } + else if (view == 3) { + std::vector eps; + Plex_Cache_GetEpisodes(CurrentSeasonKey(), eps); + if (i >= (int)eps.size()) return new CStrObject; + const PlexEpisode& e = eps[i]; + body = ""; + if (!e.index.empty()) { body += e.index; body += ". "; } + body += e.title; + if (!e.summary.empty()) { body += "\n\n"; body += e.summary; } + } + const size_t kMax = 1200; + if (body.size() > kMax) { + body.resize(kMax - 1); + body += "\xE2\x80\xA6"; + } + return new CStrObject(_T(body.c_str())); + } + + // Truncated plot blurb for inline display. Items + episodes only. + CStrObject* GetSummary(int view, int i) + { + if (i < 0) return new CStrObject; + std::string s; + if (view == 1) { + std::vector items = CurrentItems(); + if (i >= (int)items.size()) return new CStrObject; + s = items[i].summary; + } else if (view == 3) { + std::vector eps; + Plex_Cache_GetEpisodes(CurrentSeasonKey(), eps); + if (i >= (int)eps.size()) return new CStrObject; + s = eps[i].summary; + } else { + return new CStrObject; + } + const size_t kMax = 140; + if (s.size() > kMax) { + s.resize(kMax - 1); + s += "\xE2\x80\xA6"; // ellipsis + } + return new CStrObject(_T(s.c_str())); + } + + int GetItemCount() { return (int)CurrentItems().size(); } + + CStrObject* GetItemTitle(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return new CStrObject; + const PlexItem& it = items[i]; + std::string s = it.title; + if (!it.year.empty()) { s += " ("; s += it.year; s += ")"; } + return new CStrObject(_T(s.c_str())); + } + + void QueueItemArt(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return; + const PlexItem& it = items[i]; + if (!it.thumbUrl.empty()) Plex_QueueArtDownload(it.ratingKey, it.thumbUrl); + } + + CStrObject* GetItemArtPath(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return new CStrObject; + return new CStrObject(_T(Plex_ArtCachePath(items[i].ratingKey).c_str())); + } + + void QueueSeasonArt(int i) + { + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (showKey.empty()) return; + Plex_Cache_GetSeasons(showKey, seasons); + if (i < 0 || i >= (int)seasons.size()) return; + const PlexSeason& s = seasons[i]; + if (!s.thumbUrl.empty()) Plex_QueueArtDownload(s.ratingKey, s.thumbUrl); + } + + CStrObject* GetSeasonArtPath(int i) + { + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (showKey.empty()) return new CStrObject; + Plex_Cache_GetSeasons(showKey, seasons); + if (i < 0 || i >= (int)seasons.size()) return new CStrObject; + return new CStrObject(_T(Plex_ArtCachePath(seasons[i].ratingKey).c_str())); + } + + void QueueEpisodeArt(int i) + { + std::vector eps; + std::string seasonKey = CurrentSeasonKey(); + if (seasonKey.empty()) return; + Plex_Cache_GetEpisodes(seasonKey, eps); + if (i < 0 || i >= (int)eps.size()) return; + const PlexEpisode& e = eps[i]; + if (!e.thumbUrl.empty()) Plex_QueueArtDownload(e.ratingKey, e.thumbUrl); + } + + CStrObject* GetEpisodeArtPath(int i) + { + std::vector eps; + std::string seasonKey = CurrentSeasonKey(); + if (seasonKey.empty()) return new CStrObject; + Plex_Cache_GetEpisodes(seasonKey, eps); + if (i < 0 || i >= (int)eps.size()) return new CStrObject; + return new CStrObject(_T(Plex_ArtCachePath(eps[i].ratingKey).c_str())); + } + + void PlayRatingKey(const std::string& ratingKey, + const std::string& title, + const std::string& subtitle) + { + if (m_serverUri.empty() || ratingKey.empty()) return; + std::string url = Plex_ResolveStreamUrl(m_serverUri, ratingKey); + if (url.empty()) { + fprintf(stderr, "[Plex] stream URL resolve failed for %s\n", + ratingKey.c_str()); + return; + } + if (!MediaPlayer_Open(url.c_str())) { + fprintf(stderr, "[Plex] MediaPlayer_Open failed for %s\n", + url.c_str()); + return; + } + extern bool g_mediaFullscreen; + extern char g_mediaFullscreenTitle[256]; + extern char g_mediaFullscreenSubtitle[256]; + extern void ApplyEffectiveMute_Public(); + g_mediaFullscreen = true; + ApplyEffectiveMute_Public(); + strncpy(g_mediaFullscreenTitle, title.c_str(), + sizeof(g_mediaFullscreenTitle) - 1); + g_mediaFullscreenTitle[sizeof(g_mediaFullscreenTitle) - 1] = 0; + strncpy(g_mediaFullscreenSubtitle, subtitle.c_str(), + sizeof(g_mediaFullscreenSubtitle) - 1); + g_mediaFullscreenSubtitle[sizeof(g_mediaFullscreenSubtitle) - 1] = 0; + } + + void PlayItem(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return; + const PlexItem& it = items[i]; + std::string sub = "Plex"; + if (!it.year.empty()) { sub += " • "; sub += it.year; } + PlayRatingKey(it.ratingKey, it.title, sub); + } + + // ----- TV drill: show -> seasons -> episodes ----- + + std::string CurrentShowKey() + { + std::vector items = CurrentItems(); + if (m_currentShow < 0 || m_currentShow >= (int)items.size()) return ""; + return items[m_currentShow].ratingKey; + } + + std::string CurrentSeasonKey() + { + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (showKey.empty()) return ""; + Plex_Cache_GetSeasons(showKey, seasons); + if (m_currentSeason < 0 || m_currentSeason >= (int)seasons.size()) return ""; + return seasons[m_currentSeason].ratingKey; + } + + void DrillIntoShow(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return; + const PlexItem& it = items[i]; + if (it.type != "show") return; + m_currentShow = i; + m_currentSeason = -1; + std::vector dummy; + (void)Plex_Cache_GetSeasons(it.ratingKey, dummy); + } + + int GetSeasonCount() + { + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (showKey.empty()) return 0; + Plex_Cache_GetSeasons(showKey, seasons); + return (int)seasons.size(); + } + + CStrObject* GetSeasonTitle(int i) + { + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (showKey.empty()) return new CStrObject; + Plex_Cache_GetSeasons(showKey, seasons); + if (i < 0 || i >= (int)seasons.size()) return new CStrObject; + return new CStrObject(_T(seasons[i].title.c_str())); + } + + void DrillIntoSeason(int i) + { + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (showKey.empty()) return; + Plex_Cache_GetSeasons(showKey, seasons); + if (i < 0 || i >= (int)seasons.size()) return; + m_currentSeason = i; + std::vector dummy; + (void)Plex_Cache_GetEpisodes(seasons[i].ratingKey, dummy); + } + + int GetEpisodeCount() + { + std::vector eps; + std::string seasonKey = CurrentSeasonKey(); + if (seasonKey.empty()) return 0; + Plex_Cache_GetEpisodes(seasonKey, eps); + return (int)eps.size(); + } + + CStrObject* GetEpisodeTitle(int i) + { + std::vector eps; + std::string seasonKey = CurrentSeasonKey(); + if (seasonKey.empty()) return new CStrObject; + Plex_Cache_GetEpisodes(seasonKey, eps); + if (i < 0 || i >= (int)eps.size()) return new CStrObject; + const PlexEpisode& e = eps[i]; + std::string s; + if (!e.index.empty()) { s += e.index; s += ". "; } + s += e.title; + return new CStrObject(_T(s.c_str())); + } + + void PlayEpisode(int i) + { + std::vector eps; + std::string seasonKey = CurrentSeasonKey(); + if (seasonKey.empty()) return; + Plex_Cache_GetEpisodes(seasonKey, eps); + if (i < 0 || i >= (int)eps.size()) return; + const PlexEpisode& e = eps[i]; + std::string sub; + std::vector items = CurrentItems(); + std::vector seasons; + std::string showKey = CurrentShowKey(); + if (!showKey.empty()) Plex_Cache_GetSeasons(showKey, seasons); + if (m_currentShow >= 0 && m_currentShow < (int)items.size()) + sub = items[m_currentShow].title; + if (m_currentSeason >= 0 && m_currentSeason < (int)seasons.size()) { + sub += " • S"; + sub += seasons[m_currentSeason].index; + if (!e.index.empty()) { sub += "E"; sub += e.index; } + } + PlayRatingKey(e.ratingKey, e.title, sub); + } + + int IsSignedIn() { return Plex_HasToken() ? 1 : 0; } +}; + +IMPLEMENT_NODE("PlexLibrary", CPlexLibrary, CNode) + +START_NODE_PROPS(CPlexLibrary, CNode) +END_NODE_PROPS() + +#undef _FND_CLASS +#define _FND_CLASS CPlexLibrary +START_NODE_FUN(CPlexLibrary, CNode) + NODE_FUN_IV(GetCount) + NODE_FUN_SI(GetName) + NODE_FUN_SI(GetMeta) + NODE_FUN_VI(SetCurrent) + NODE_FUN_SV(GetServerName) + NODE_FUN_IV(IsSyncReady) + NODE_FUN_SV(GetSyncPhase) + NODE_FUN_IV(GetSyncProgress) + NODE_FUN_IV(GetItemCount) + NODE_FUN_SI(GetItemTitle) + NODE_FUN_SI(GetItemType) + NODE_FUN_VI(QueueItemArt) + NODE_FUN_SI(GetItemArtPath) + NODE_FUN_VI(PlayItem) + NODE_FUN_SII(GetSubtitle) + NODE_FUN_SII(GetSummary) + NODE_FUN_SII(GetInfoText) + NODE_FUN_VI(DrillIntoShow) + NODE_FUN_IV(GetSeasonCount) + NODE_FUN_SI(GetSeasonTitle) + NODE_FUN_VI(DrillIntoSeason) + NODE_FUN_VI(QueueSeasonArt) + NODE_FUN_SI(GetSeasonArtPath) + NODE_FUN_IV(GetEpisodeCount) + NODE_FUN_SI(GetEpisodeTitle) + NODE_FUN_VI(PlayEpisode) + NODE_FUN_VI(QueueEpisodeArt) + NODE_FUN_SI(GetEpisodeArtPath) + NODE_FUN_IV(IsSignedIn) +END_NODE_FUN() + + +// ============================================================================ +// CJellyfinLibrary -- mirror of CPlexLibrary over Jellyfin's API surface. +// ============================================================================ + +#include "jellyfin_client.h" + +class CJellyfinLibrary : public CNode +{ +public: + CJellyfinLibrary() + : m_currentLib(-1), m_currentShow(-1), m_currentSeason(-1) {} + + DECLARE_NODE(CJellyfinLibrary, CNode) + DECLARE_NODE_PROPS() + DECLARE_NODE_FUNCTIONS() + + int m_currentLib; + int m_currentShow; + int m_currentSeason; + + void EnsureLibrariesPending() + { + if (!Jellyfin_HasToken()) return; + Jellyfin_StartSync(); + } + + int GetCount() + { + EnsureLibrariesPending(); + return (int)Jellyfin_Cache_GetLibraries().size(); + } + + CStrObject* GetName(int i) + { + std::vector libs = Jellyfin_Cache_GetLibraries(); + if (i < 0 || i >= (int)libs.size()) return new CStrObject; + return new CStrObject(_T(libs[i].name.c_str())); + } + + CStrObject* GetMeta(int i) + { + std::vector libs = Jellyfin_Cache_GetLibraries(); + if (i < 0 || i >= (int)libs.size()) return new CStrObject; + return new CStrObject(_T(libs[i].type.c_str())); + } + + void SetCurrent(int i) + { + if (i == m_currentLib) return; + m_currentLib = i; + m_currentShow = -1; + m_currentSeason = -1; + } + + CStrObject* GetServerName() + { + EnsureLibrariesPending(); + // Jellyfin doesn't surface a friendly name in /Views; use the + // signed-in user's name as the breadcrumb anchor. + return new CStrObject(_T(Jellyfin_GetUserName().c_str())); + } + + int IsSyncReady() { EnsureLibrariesPending(); return Jellyfin_SyncReady() ? 1 : 0; } + CStrObject* GetSyncPhase() { return new CStrObject(_T(Jellyfin_SyncPhase().c_str())); } + int GetSyncProgress(){ return Jellyfin_SyncProgress(); } + + std::vector CurrentItems() + { + std::vector libs = Jellyfin_Cache_GetLibraries(); + if (m_currentLib < 0 || m_currentLib >= (int)libs.size()) return {}; + return Jellyfin_Cache_GetItems(libs[m_currentLib].id); + } + + int GetItemCount() { return (int)CurrentItems().size(); } + + CStrObject* GetItemTitle(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return new CStrObject; + const JellyfinItem& it = items[i]; + std::string s = it.name; + if (!it.year.empty()) { s += " ("; s += it.year; s += ")"; } + return new CStrObject(_T(s.c_str())); + } + + CStrObject* GetItemType(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return new CStrObject; + // Map Jellyfin type to the lowercase "show" the wrapper checks for. + std::string t = items[i].type; + if (t == "Series") return new CStrObject(_T("show")); + if (t == "Movie") return new CStrObject(_T("movie")); + std::string lc = t; + for (auto& c : lc) c = (char)tolower((unsigned char)c); + return new CStrObject(_T(lc.c_str())); + } + + void QueueItemArt(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return; + Jellyfin_QueueArtDownload(items[i].id, items[i].primaryTag); + } + + CStrObject* GetItemArtPath(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return new CStrObject; + return new CStrObject(_T(Jellyfin_ArtCachePath(items[i].id).c_str())); + } + + std::string CurrentShowId() + { + std::vector items = CurrentItems(); + if (m_currentShow < 0 || m_currentShow >= (int)items.size()) return ""; + return items[m_currentShow].id; + } + + std::string CurrentSeasonId() + { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return ""; + Jellyfin_Cache_GetSeasons(showId, seasons); + if (m_currentSeason < 0 || m_currentSeason >= (int)seasons.size()) return ""; + return seasons[m_currentSeason].id; + } + + void PlayRatingKey(const std::string& itemId, + const std::string& title, + const std::string& subtitle) + { + std::string url = Jellyfin_StreamUrl(itemId); + if (url.empty()) { + fprintf(stderr, "[Jellyfin] stream URL build failed for %s\n", itemId.c_str()); + return; + } + if (!MediaPlayer_Open(url.c_str())) { + fprintf(stderr, "[Jellyfin] MediaPlayer_Open failed\n"); + return; + } + extern bool g_mediaFullscreen; + extern char g_mediaFullscreenTitle[256]; + extern char g_mediaFullscreenSubtitle[256]; + extern void ApplyEffectiveMute_Public(); + g_mediaFullscreen = true; + ApplyEffectiveMute_Public(); + strncpy(g_mediaFullscreenTitle, title.c_str(), + sizeof(g_mediaFullscreenTitle) - 1); + g_mediaFullscreenTitle[sizeof(g_mediaFullscreenTitle) - 1] = 0; + strncpy(g_mediaFullscreenSubtitle, subtitle.c_str(), + sizeof(g_mediaFullscreenSubtitle) - 1); + g_mediaFullscreenSubtitle[sizeof(g_mediaFullscreenSubtitle) - 1] = 0; + } + + void PlayItem(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return; + const JellyfinItem& it = items[i]; + std::string sub = "Jellyfin"; + if (!it.year.empty()) { sub += " \xE2\x80\xA2 "; sub += it.year; } + PlayRatingKey(it.id, it.name, sub); + } + + void DrillIntoShow(int i) + { + std::vector items = CurrentItems(); + if (i < 0 || i >= (int)items.size()) return; + const JellyfinItem& it = items[i]; + if (it.type != "Series") return; + m_currentShow = i; + m_currentSeason = -1; + std::vector dummy; + (void)Jellyfin_Cache_GetSeasons(it.id, dummy); + } + + int GetSeasonCount() + { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return 0; + Jellyfin_Cache_GetSeasons(showId, seasons); + return (int)seasons.size(); + } + + CStrObject* GetSeasonTitle(int i) + { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return new CStrObject; + Jellyfin_Cache_GetSeasons(showId, seasons); + if (i < 0 || i >= (int)seasons.size()) return new CStrObject; + return new CStrObject(_T(seasons[i].name.c_str())); + } + + void DrillIntoSeason(int i) + { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return; + Jellyfin_Cache_GetSeasons(showId, seasons); + if (i < 0 || i >= (int)seasons.size()) return; + m_currentSeason = i; + std::vector dummy; + (void)Jellyfin_Cache_GetEpisodes(seasons[i].id, dummy); + } + + void QueueSeasonArt(int i) + { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return; + Jellyfin_Cache_GetSeasons(showId, seasons); + if (i < 0 || i >= (int)seasons.size()) return; + Jellyfin_QueueArtDownload(seasons[i].id, seasons[i].primaryTag); + } + + CStrObject* GetSeasonArtPath(int i) + { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return new CStrObject; + Jellyfin_Cache_GetSeasons(showId, seasons); + if (i < 0 || i >= (int)seasons.size()) return new CStrObject; + return new CStrObject(_T(Jellyfin_ArtCachePath(seasons[i].id).c_str())); + } + + int GetEpisodeCount() + { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return 0; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + return (int)eps.size(); + } + + CStrObject* GetEpisodeTitle(int i) + { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return new CStrObject; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i < 0 || i >= (int)eps.size()) return new CStrObject; + const JellyfinEpisode& e = eps[i]; + std::string s; + if (!e.index.empty()) { s += e.index; s += ". "; } + s += e.name; + return new CStrObject(_T(s.c_str())); + } + + void PlayEpisode(int i) + { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i < 0 || i >= (int)eps.size()) return; + const JellyfinEpisode& e = eps[i]; + std::vector items = CurrentItems(); + std::vector seasons; + std::string showId = CurrentShowId(); + if (!showId.empty()) Jellyfin_Cache_GetSeasons(showId, seasons); + std::string sub; + if (m_currentShow >= 0 && m_currentShow < (int)items.size()) + sub = items[m_currentShow].name; + if (m_currentSeason >= 0 && m_currentSeason < (int)seasons.size()) { + sub += " \xE2\x80\xA2 S"; + sub += seasons[m_currentSeason].index; + if (!e.index.empty()) { sub += "E"; sub += e.index; } + } + PlayRatingKey(e.id, e.name, sub); + } + + void QueueEpisodeArt(int i) + { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i < 0 || i >= (int)eps.size()) return; + Jellyfin_QueueArtDownload(eps[i].id, eps[i].primaryTag); + } + + CStrObject* GetEpisodeArtPath(int i) + { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return new CStrObject; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i < 0 || i >= (int)eps.size()) return new CStrObject; + return new CStrObject(_T(Jellyfin_ArtCachePath(eps[i].id).c_str())); + } + + CStrObject* GetSubtitle(int view, int i) + { + if (i < 0) return new CStrObject; + std::string s; + if (view == 1) { + std::vector items = CurrentItems(); + if (i >= (int)items.size()) return new CStrObject; + const JellyfinItem& it = items[i]; + std::string t = it.type; + for (auto& c : t) c = (char)toupper((unsigned char)c); + s = t; + if (!it.year.empty()) { s += " \xE2\x80\xA2 "; s += it.year; } + return new CStrObject(_T(s.c_str())); + } + if (view == 2) { + std::vector seasons; + std::string showId = CurrentShowId(); + if (showId.empty()) return new CStrObject; + Jellyfin_Cache_GetSeasons(showId, seasons); + if (i >= (int)seasons.size()) return new CStrObject; + s = "SEASON "; + s += seasons[i].index; + return new CStrObject(_T(s.c_str())); + } + if (view == 3) { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return new CStrObject; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i >= (int)eps.size()) return new CStrObject; + s = "S"; + std::vector seasons; + std::string showId = CurrentShowId(); + if (!showId.empty()) Jellyfin_Cache_GetSeasons(showId, seasons); + if (m_currentSeason >= 0 && m_currentSeason < (int)seasons.size()) + s += seasons[m_currentSeason].index; + if (!eps[i].index.empty()) { s += "E"; s += eps[i].index; } + return new CStrObject(_T(s.c_str())); + } + return new CStrObject; + } + + CStrObject* GetSummary(int view, int i) + { + if (i < 0) return new CStrObject; + std::string s; + if (view == 1) { + std::vector items = CurrentItems(); + if (i >= (int)items.size()) return new CStrObject; + s = items[i].overview; + } else if (view == 3) { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return new CStrObject; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i >= (int)eps.size()) return new CStrObject; + s = eps[i].overview; + } else { + return new CStrObject; + } + const size_t kMax = 140; + if (s.size() > kMax) { + s.resize(kMax - 1); + s += "\xE2\x80\xA6"; + } + return new CStrObject(_T(s.c_str())); + } + + CStrObject* GetInfoText(int view, int i) + { + if (i < 0) return new CStrObject; + std::string body; + if (view == 1) { + std::vector items = CurrentItems(); + if (i >= (int)items.size()) return new CStrObject; + const JellyfinItem& it = items[i]; + body = it.name; + if (!it.year.empty()) { body += " ("; body += it.year; body += ")"; } + if (!it.overview.empty()) { body += "\n\n"; body += it.overview; } + } + else if (view == 2) { + std::vector items = CurrentItems(); + if (m_currentShow < 0 || m_currentShow >= (int)items.size()) return new CStrObject; + const JellyfinItem& sh = items[m_currentShow]; + std::vector seasons; + Jellyfin_Cache_GetSeasons(sh.id, seasons); + body = sh.name; + if (i < (int)seasons.size()) { body += " - "; body += seasons[i].name; } + if (!sh.overview.empty()) { body += "\n\n"; body += sh.overview; } + } + else if (view == 3) { + std::vector eps; + std::string seasonId = CurrentSeasonId(); + if (seasonId.empty()) return new CStrObject; + Jellyfin_Cache_GetEpisodes(seasonId, eps); + if (i >= (int)eps.size()) return new CStrObject; + const JellyfinEpisode& e = eps[i]; + body = ""; + if (!e.index.empty()) { body += e.index; body += ". "; } + body += e.name; + if (!e.overview.empty()) { body += "\n\n"; body += e.overview; } + } + const size_t kMax = 1200; + if (body.size() > kMax) { + body.resize(kMax - 1); + body += "\xE2\x80\xA6"; + } + return new CStrObject(_T(body.c_str())); + } + + int IsSignedIn() { return Jellyfin_HasToken() ? 1 : 0; } +}; + +IMPLEMENT_NODE("JellyfinLibrary", CJellyfinLibrary, CNode) + +START_NODE_PROPS(CJellyfinLibrary, CNode) +END_NODE_PROPS() + +#undef _FND_CLASS +#define _FND_CLASS CJellyfinLibrary +START_NODE_FUN(CJellyfinLibrary, CNode) + NODE_FUN_IV(GetCount) + NODE_FUN_SI(GetName) + NODE_FUN_SI(GetMeta) + NODE_FUN_VI(SetCurrent) + NODE_FUN_SV(GetServerName) + NODE_FUN_IV(IsSyncReady) + NODE_FUN_SV(GetSyncPhase) + NODE_FUN_IV(GetSyncProgress) + NODE_FUN_IV(GetItemCount) + NODE_FUN_SI(GetItemTitle) + NODE_FUN_SI(GetItemType) + NODE_FUN_VI(QueueItemArt) + NODE_FUN_SI(GetItemArtPath) + NODE_FUN_VI(PlayItem) + NODE_FUN_SII(GetSubtitle) + NODE_FUN_SII(GetSummary) + NODE_FUN_SII(GetInfoText) + NODE_FUN_VI(DrillIntoShow) + NODE_FUN_IV(GetSeasonCount) + NODE_FUN_SI(GetSeasonTitle) + NODE_FUN_VI(DrillIntoSeason) + NODE_FUN_VI(QueueSeasonArt) + NODE_FUN_SI(GetSeasonArtPath) + NODE_FUN_IV(GetEpisodeCount) + NODE_FUN_SI(GetEpisodeTitle) + NODE_FUN_VI(PlayEpisode) + NODE_FUN_VI(QueueEpisodeArt) + NODE_FUN_SI(GetEpisodeArtPath) + NODE_FUN_IV(IsSignedIn) +END_NODE_FUN() + + // ============================================================================ // CGamesCollection - XAP-callable view over the VGames database (games.ini), // filtered by category. Pairs with the desktop harddrive scene to replace diff --git a/theseus/desktop/http_util.cpp b/theseus/desktop/http_util.cpp index 6efd5e3..5a21b49 100644 --- a/theseus/desktop/http_util.cpp +++ b/theseus/desktop/http_util.cpp @@ -11,9 +11,8 @@ static void EnsureGlobalInit() { static void ApplyCommonOpts(CURL* h, const char* url) { curl_easy_setopt(h, CURLOPT_URL, url); curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(h, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(h, CURLOPT_TIMEOUT, 60L); curl_easy_setopt(h, CURLOPT_USERAGENT, "Theseus-Dashboard/1.0"); - curl_easy_setopt(h, CURLOPT_FAILONERROR, 1L); } static size_t WriteToString(void* ptr, size_t size, size_t nmemb, void* user) { @@ -25,19 +24,63 @@ static size_t WriteToFile(void* ptr, size_t size, size_t nmemb, void* user) { return fwrite(ptr, size, nmemb, (FILE*)user); } -std::string Http_GetToString(const std::string& url) { +// Build a curl_slist from our HttpHeaders. Caller frees with curl_slist_free_all. +static curl_slist* BuildSlist(const HttpHeaders& headers) { + curl_slist* list = nullptr; + for (const HttpHeader& h : headers) { + std::string line = h.name + ": " + h.value; + list = curl_slist_append(list, line.c_str()); + } + return list; +} + +HttpResponse Http_Get(const std::string& url, const HttpHeaders& headers) { EnsureGlobalInit(); - std::string out; + HttpResponse out; CURL* h = curl_easy_init(); if (!h) return out; ApplyCommonOpts(h, url.c_str()); curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, WriteToString); - curl_easy_setopt(h, CURLOPT_WRITEDATA, &out); - if (curl_easy_perform(h) != CURLE_OK) out.clear(); + curl_easy_setopt(h, CURLOPT_WRITEDATA, &out.body); + curl_slist* slist = BuildSlist(headers); + if (slist) curl_easy_setopt(h, CURLOPT_HTTPHEADER, slist); + if (curl_easy_perform(h) == CURLE_OK) { + curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &out.status); + } + if (slist) curl_slist_free_all(slist); curl_easy_cleanup(h); return out; } +HttpResponse Http_Post(const std::string& url, + const std::string& body, + const HttpHeaders& headers) { + EnsureGlobalInit(); + HttpResponse out; + CURL* h = curl_easy_init(); + if (!h) return out; + ApplyCommonOpts(h, url.c_str()); + curl_easy_setopt(h, CURLOPT_POST, 1L); + curl_easy_setopt(h, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)body.size()); + curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, WriteToString); + curl_easy_setopt(h, CURLOPT_WRITEDATA, &out.body); + curl_slist* slist = BuildSlist(headers); + if (slist) curl_easy_setopt(h, CURLOPT_HTTPHEADER, slist); + if (curl_easy_perform(h) == CURLE_OK) { + curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &out.status); + } + if (slist) curl_slist_free_all(slist); + curl_easy_cleanup(h); + return out; +} + +std::string Http_GetToString(const std::string& url) { + HttpResponse r = Http_Get(url); + if (!r.ok()) return ""; + return r.body; +} + bool Http_GetToFile(const std::string& url, const std::string& outPath) { EnsureGlobalInit(); FILE* fp = fopen(outPath.c_str(), "wb"); @@ -45,6 +88,7 @@ bool Http_GetToFile(const std::string& url, const std::string& outPath) { CURL* h = curl_easy_init(); if (!h) { fclose(fp); remove(outPath.c_str()); return false; } ApplyCommonOpts(h, url.c_str()); + curl_easy_setopt(h, CURLOPT_FAILONERROR, 1L); curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, WriteToFile); curl_easy_setopt(h, CURLOPT_WRITEDATA, fp); CURLcode rc = curl_easy_perform(h); diff --git a/theseus/desktop/http_util.h b/theseus/desktop/http_util.h index 32a0ec2..bfcc038 100644 --- a/theseus/desktop/http_util.h +++ b/theseus/desktop/http_util.h @@ -1,11 +1,25 @@ #pragma once -// Shared libcurl wrappers. One boilerplate, two output flavors. +// Shared libcurl wrappers. #include +#include -// GET url into a string. Empty on failure or non-2xx. +struct HttpHeader { std::string name, value; }; +using HttpHeaders = std::vector; + +struct HttpResponse { + long status = 0; // 0 on transport failure + std::string body; + bool ok() const { return status >= 200 && status < 300; } +}; + +// String/file GET shims. Empty/false on failure. std::string Http_GetToString(const std::string& url); +bool Http_GetToFile(const std::string& url, const std::string& outPath); -// GET url straight to disk. Removes partial file on failure. -bool Http_GetToFile(const std::string& url, const std::string& outPath); +// Header + status-aware GET/POST. Non-2xx is NOT a failure; caller decides. +HttpResponse Http_Get(const std::string& url, const HttpHeaders& headers = {}); +HttpResponse Http_Post(const std::string& url, + const std::string& body = "", + const HttpHeaders& headers = {}); diff --git a/theseus/desktop/jellyfin_client.cpp b/theseus/desktop/jellyfin_client.cpp new file mode 100644 index 0000000..b194002 --- /dev/null +++ b/theseus/desktop/jellyfin_client.cpp @@ -0,0 +1,725 @@ +// jellyfin_client.cpp: Jellyfin Media Server HTTPS client. + +#include "jellyfin_client.h" +#include "http_util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char g_jellyfinUrl[512]; +extern char g_jellyfinToken[256]; +extern char g_jellyfinUserId[64]; +extern char g_jellyfinClientId[64]; +extern char g_jellyfinUserName[128]; +extern void SaveDesktopSettings(); + + +// ============================================================================ +// Identity + headers +// ============================================================================ + +static void GenerateUUID(char out[37]) +{ + unsigned char b[16]; + for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xFF); + b[6] = (b[6] & 0x0F) | 0x40; + b[8] = (b[8] & 0x3F) | 0x80; + snprintf(out, 37, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], + b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]); +} + +// Jellyfin/Emby auth header. Inherits Emby's quirky format: a single +// Authorization header with comma-separated key=value pairs. +static std::string AuthHeader(const char* token) +{ + std::string h = "MediaBrowser Client=\"UIX Desktop\", "; + h += "Device=\"UIX Desktop\", "; + h += "DeviceId=\""; + h += g_jellyfinClientId; + h += "\", Version=\"0.3\""; + if (token && *token) { + h += ", Token=\""; + h += token; + h += "\""; + } + return h; +} + +static HttpHeaders JellyfinHeaders(const char* token) +{ + HttpHeaders h; + h.push_back({ "Accept", "application/json" }); + h.push_back({ "Content-Type", "application/json" }); + h.push_back({ "Authorization", AuthHeader(token) }); + return h; +} + + +// ============================================================================ +// Minimal JSON helpers (same shape as plex_client.cpp). Jellyfin's JSON keys +// are PascalCase ("AccessToken", "Name") -- pass them literally. +// ============================================================================ + +static size_t Json_FindKey(const std::string& s, const char* key, size_t from = 0) +{ + std::string needle = "\""; + needle += key; + needle += "\""; + size_t k = s.find(needle, from); + if (k == std::string::npos) return std::string::npos; + size_t c = s.find(':', k + needle.size()); + if (c == std::string::npos) return std::string::npos; + c++; + while (c < s.size() && (s[c] == ' ' || s[c] == '\t')) c++; + return c; +} + +static std::string Json_GetString(const std::string& s, const char* key, size_t from = 0) +{ + size_t v = Json_FindKey(s, key, from); + if (v == std::string::npos || v >= s.size() || s[v] != '"') return ""; + v++; + size_t end = v; + while (end < s.size()) { + if (s[end] == '\\' && end + 1 < s.size()) { end += 2; continue; } + if (s[end] == '"') break; + end++; + } + if (end >= s.size()) return ""; + return s.substr(v, end - v); +} + +static bool Json_GetBool(const std::string& s, const char* key) +{ + size_t v = Json_FindKey(s, key); + if (v == std::string::npos) return false; + return strncmp(s.c_str() + v, "true", 4) == 0; +} + +static int Json_GetInt(const std::string& s, const char* key, size_t from = 0) +{ + size_t v = Json_FindKey(s, key, from); + if (v == std::string::npos) return 0; + return (int)strtol(s.c_str() + v, NULL, 10); +} + +static size_t Json_FindArray(const std::string& s, const char* key) +{ + size_t v = Json_FindKey(s, key); + if (v == std::string::npos) return std::string::npos; + while (v < s.size() && (s[v] == ' ' || s[v] == '\t')) v++; + if (v >= s.size() || s[v] != '[') return std::string::npos; + return v; +} + +static std::vector Json_SplitArray(const std::string& s, size_t arrStart) +{ + std::vector out; + if (arrStart >= s.size() || s[arrStart] != '[') return out; + size_t i = arrStart + 1; + while (i < s.size()) { + while (i < s.size() && (s[i] == ' ' || s[i] == '\n' || s[i] == '\t' || s[i] == ',')) i++; + if (i >= s.size() || s[i] == ']') break; + if (s[i] != '{') { i++; continue; } + size_t start = i; + int depth = 0; + bool inStr = false; + for (; i < s.size(); i++) { + char c = s[i]; + if (inStr) { + if (c == '\\' && i + 1 < s.size()) { i++; continue; } + if (c == '"') inStr = false; + } else { + if (c == '"') inStr = true; + else if (c == '{') depth++; + else if (c == '}') { depth--; if (depth == 0) { i++; break; } } + } + } + out.push_back(s.substr(start, i - start)); + } + return out; +} + +// Pull ImageTags.Primary out of an item object. Nested object lookup. +static std::string ParsePrimaryTag(const std::string& obj) +{ + size_t tags = Json_FindKey(obj, "ImageTags"); + if (tags == std::string::npos) return ""; + return Json_GetString(obj, "Primary", tags); +} + + +// ============================================================================ +// State +// ============================================================================ + +static std::mutex g_mtx; + +static std::atomic s_qcInFlight{false}; +static std::atomic s_qcCancel{false}; +static std::string s_qcCode; +static std::string s_qcSecret; +static std::thread s_qcThread; + +// ---------- Pre-stage cache ---------- +static std::atomic s_syncReady{false}; +static std::atomic s_syncRunning{false}; +static std::atomic s_syncProgress{0}; +static std::string s_syncPhase; +static std::thread s_syncThread; +static std::vector s_cacheLibs; +static std::map> s_cacheItems; +static std::map> s_cacheSeasons; +static std::map> s_cacheEpisodes; + +// ---------- Art download queue ---------- +static std::mutex s_artMtx; +static std::map*> s_artInFlight; + + +// ============================================================================ +// Init / shutdown +// ============================================================================ + +void Jellyfin_Init() +{ + static bool done = false; + if (done) return; + done = true; + + srand((unsigned)time(NULL)); + if (g_jellyfinClientId[0] == 0) { + GenerateUUID(g_jellyfinClientId); + SaveDesktopSettings(); + } + fprintf(stderr, "[Jellyfin] init (token=%s, url=%s)\n", + g_jellyfinToken[0] ? "yes" : "no", + g_jellyfinUrl[0] ? g_jellyfinUrl : "(unset)"); +} + +static void JoinIf(std::thread& t) { + if (t.joinable()) t.join(); +} + +void Jellyfin_Shutdown() +{ + s_qcCancel = true; + JoinIf(s_qcThread); + JoinIf(s_syncThread); +} + +bool Jellyfin_HasToken() { return g_jellyfinToken[0] != 0; } + +void Jellyfin_SignOut() +{ + g_jellyfinToken[0] = 0; + g_jellyfinUserId[0] = 0; + g_jellyfinUserName[0] = 0; + std::lock_guard lk(g_mtx); + s_qcCode.clear(); + s_qcSecret.clear(); + s_cacheLibs.clear(); + s_cacheItems.clear(); + s_cacheSeasons.clear(); + s_cacheEpisodes.clear(); + s_syncReady = false; + s_syncRunning = false; + s_syncProgress = 0; + s_syncPhase.clear(); +} + + +// ============================================================================ +// Server URL +// ============================================================================ + +std::string Jellyfin_GetServerUrl() +{ + return g_jellyfinUrl; +} + +void Jellyfin_SetServerUrl(const std::string& url) +{ + std::string u = url; + while (!u.empty() && u.back() == '/') u.pop_back(); + if (u.find("://") == std::string::npos && !u.empty()) { + u = "http://" + u; + } + strncpy(g_jellyfinUrl, u.c_str(), sizeof(g_jellyfinUrl) - 1); + g_jellyfinUrl[sizeof(g_jellyfinUrl) - 1] = 0; + SaveDesktopSettings(); +} + +std::string Jellyfin_GetUserName() +{ + return g_jellyfinUserName; +} + + +// ============================================================================ +// Quick Connect +// ============================================================================ + +static void QuickConnectWorker() +{ + if (g_jellyfinUrl[0] == 0) { + fprintf(stderr, "[Jellyfin] no server URL set\n"); + s_qcInFlight = false; + return; + } + + // 1. POST /QuickConnect/Initiate -> { Code, Secret } + std::string url = std::string(g_jellyfinUrl) + "/QuickConnect/Initiate"; + HttpResponse r = Http_Post(url, "", JellyfinHeaders("")); + if (!r.ok()) { + fprintf(stderr, "[Jellyfin] QC init failed (status %ld)\n", r.status); + s_qcInFlight = false; + return; + } + std::string code = Json_GetString(r.body, "Code"); + std::string secret = Json_GetString(r.body, "Secret"); + if (code.empty() || secret.empty()) { + fprintf(stderr, "[Jellyfin] QC init bad json: %s\n", r.body.c_str()); + s_qcInFlight = false; + return; + } + + { + std::lock_guard lk(g_mtx); + s_qcCode = code; + s_qcSecret = secret; + } + fprintf(stderr, "[Jellyfin] QC code = %s. Approve at %s\n", + code.c_str(), g_jellyfinUrl); + + // 2. Poll /QuickConnect/Connect?Secret=... until Authenticated == true. + std::string pollUrl = std::string(g_jellyfinUrl) + + "/QuickConnect/Connect?Secret=" + secret; + auto deadline = std::chrono::steady_clock::now() + std::chrono::minutes(10); + bool authenticated = false; + while (std::chrono::steady_clock::now() < deadline) { + if (s_qcCancel) break; + std::this_thread::sleep_for(std::chrono::seconds(2)); + if (s_qcCancel) break; + + HttpResponse p = Http_Get(pollUrl, JellyfinHeaders("")); + if (!p.ok()) continue; + if (Json_GetBool(p.body, "Authenticated")) { + authenticated = true; + break; + } + } + + if (!authenticated) { + fprintf(stderr, "[Jellyfin] QC timed out or cancelled\n"); + std::lock_guard lk(g_mtx); + s_qcCode.clear(); + s_qcSecret.clear(); + s_qcInFlight = false; + return; + } + + // 3. POST /Users/AuthenticateWithQuickConnect { Secret } -> user + token + std::string body = "{\"Secret\":\"" + secret + "\"}"; + std::string authUrl = std::string(g_jellyfinUrl) + + "/Users/AuthenticateWithQuickConnect"; + HttpResponse a = Http_Post(authUrl, body, JellyfinHeaders("")); + if (!a.ok()) { + fprintf(stderr, "[Jellyfin] AuthenticateWithQuickConnect failed (status %ld)\n", a.status); + std::lock_guard lk(g_mtx); + s_qcCode.clear(); + s_qcSecret.clear(); + s_qcInFlight = false; + return; + } + + std::string accessToken = Json_GetString(a.body, "AccessToken"); + // The user object is nested: "User": { "Id": "...", "Name": "..." } + size_t userObj = Json_FindKey(a.body, "User"); + std::string userId, userName; + if (userObj != std::string::npos) { + userId = Json_GetString(a.body, "Id", userObj); + userName = Json_GetString(a.body, "Name", userObj); + } + + if (accessToken.empty() || userId.empty()) { + fprintf(stderr, "[Jellyfin] auth response missing fields: %s\n", a.body.c_str()); + } else { + strncpy(g_jellyfinToken, accessToken.c_str(), sizeof(g_jellyfinToken) - 1); + g_jellyfinToken[sizeof(g_jellyfinToken) - 1] = 0; + strncpy(g_jellyfinUserId, userId.c_str(), sizeof(g_jellyfinUserId) - 1); + g_jellyfinUserId[sizeof(g_jellyfinUserId) - 1] = 0; + strncpy(g_jellyfinUserName, userName.c_str(), sizeof(g_jellyfinUserName) - 1); + g_jellyfinUserName[sizeof(g_jellyfinUserName) - 1] = 0; + SaveDesktopSettings(); + fprintf(stderr, "[Jellyfin] signed in as %s\n", userName.c_str()); + } + + { + std::lock_guard lk(g_mtx); + s_qcCode.clear(); + s_qcSecret.clear(); + } + s_qcInFlight = false; +} + +void Jellyfin_StartQuickConnect() +{ + Jellyfin_Init(); + if (s_qcInFlight) return; + JoinIf(s_qcThread); + s_qcCancel = false; + s_qcInFlight = true; + s_qcThread = std::thread(QuickConnectWorker); +} + +void Jellyfin_CancelQuickConnect() +{ + s_qcCancel = true; +} + +bool Jellyfin_QuickConnectInFlight() { return s_qcInFlight; } + +std::string Jellyfin_GetQuickConnectCode() +{ + std::lock_guard lk(g_mtx); + return s_qcCode; +} + + +// ============================================================================ +// Library + item parsing +// ============================================================================ + +static JellyfinItem ParseItem(const std::string& obj) +{ + JellyfinItem it; + it.id = Json_GetString(obj, "Id"); + it.name = Json_GetString(obj, "Name"); + it.overview = Json_GetString(obj, "Overview"); + it.type = Json_GetString(obj, "Type"); + it.primaryTag = ParsePrimaryTag(obj); + int y = Json_GetInt(obj, "ProductionYear"); + if (y > 0) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", y); + it.year = buf; + } + return it; +} + +static std::vector FetchItemsForLibrary(const std::string& libraryId) +{ + char url[1024]; + snprintf(url, sizeof(url), + "%s/Users/%s/Items?ParentId=%s&Recursive=false&Fields=Overview,ProductionYear", + g_jellyfinUrl, g_jellyfinUserId, libraryId.c_str()); + HttpResponse r = Http_Get(url, JellyfinHeaders(g_jellyfinToken)); + std::vector out; + if (!r.ok()) { + fprintf(stderr, "[Jellyfin] items fetch failed for %s (status %ld)\n", + libraryId.c_str(), r.status); + return out; + } + size_t arr = Json_FindArray(r.body, "Items"); + if (arr == std::string::npos) return out; + for (const auto& obj : Json_SplitArray(r.body, arr)) { + JellyfinItem it = ParseItem(obj); + if (!it.id.empty()) out.push_back(it); + } + return out; +} + + +// ============================================================================ +// Sync worker (mirrors Plex_StartSync) +// ============================================================================ + +static void SetSyncPhase(const std::string& phase, int progressOf1000) +{ + std::lock_guard lk(g_mtx); + s_syncPhase = phase; + s_syncProgress = progressOf1000; +} + +static void SyncWorker() +{ + s_syncReady = false; + + SetSyncPhase("Loading library list", 0); + char url[1024]; + snprintf(url, sizeof(url), "%s/Users/%s/Views", g_jellyfinUrl, g_jellyfinUserId); + HttpResponse r = Http_Get(url, JellyfinHeaders(g_jellyfinToken)); + std::vector libs; + if (r.ok()) { + size_t arr = Json_FindArray(r.body, "Items"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(r.body, arr)) { + JellyfinLibrary lib; + lib.id = Json_GetString(obj, "Id"); + lib.name = Json_GetString(obj, "Name"); + lib.type = Json_GetString(obj, "CollectionType"); + if (!lib.id.empty()) libs.push_back(lib); + } + } + } + + std::map> itemsById; + const int libCount = (int)libs.size(); + for (int i = 0; i < libCount; i++) { + const JellyfinLibrary& lib = libs[i]; + char phaseBuf[128]; + snprintf(phaseBuf, sizeof(phaseBuf), + "Loading %s (%d/%d)", lib.name.c_str(), i + 1, libCount); + SetSyncPhase(phaseBuf, 100 + (i * 900) / (libCount > 0 ? libCount : 1)); + itemsById[lib.id] = FetchItemsForLibrary(lib.id); + } + + { + std::lock_guard lk(g_mtx); + s_cacheLibs = std::move(libs); + s_cacheItems = std::move(itemsById); + } + + SetSyncPhase("Jellyfin library ready", 1000); + s_syncReady = true; + s_syncRunning = false; + fprintf(stderr, "[Jellyfin] sync complete (%zu libraries)\n", s_cacheLibs.size()); +} + +void Jellyfin_StartSync() +{ + Jellyfin_Init(); + if (!Jellyfin_HasToken()) return; + if (s_syncRunning) return; + if (s_syncReady) return; + JoinIf(s_syncThread); + s_syncRunning = true; + s_syncThread = std::thread(SyncWorker); +} + +bool Jellyfin_SyncReady() { return s_syncReady; } +int Jellyfin_SyncProgress() { return s_syncProgress; } +std::string Jellyfin_SyncPhase() +{ + std::lock_guard lk(g_mtx); + return s_syncPhase; +} + +std::vector Jellyfin_Cache_GetLibraries() +{ + std::lock_guard lk(g_mtx); + return s_cacheLibs; +} + +std::vector Jellyfin_Cache_GetItems(const std::string& libraryId) +{ + std::lock_guard lk(g_mtx); + auto it = s_cacheItems.find(libraryId); + if (it == s_cacheItems.end()) return {}; + return it->second; +} + + +// ============================================================================ +// Seasons + episodes (lazy, fetched on first drill) +// ============================================================================ + +static std::vector FetchSeasonsForShow(const std::string& showId) +{ + char url[1024]; + snprintf(url, sizeof(url), "%s/Shows/%s/Seasons?UserId=%s", + g_jellyfinUrl, showId.c_str(), g_jellyfinUserId); + HttpResponse r = Http_Get(url, JellyfinHeaders(g_jellyfinToken)); + std::vector out; + if (!r.ok()) return out; + size_t arr = Json_FindArray(r.body, "Items"); + if (arr == std::string::npos) return out; + for (const auto& obj : Json_SplitArray(r.body, arr)) { + JellyfinSeason s; + s.id = Json_GetString(obj, "Id"); + s.name = Json_GetString(obj, "Name"); + int idx = Json_GetInt(obj, "IndexNumber"); + if (idx <= 0) continue; // skip "Specials"-like pseudo-seasons + char buf[8]; snprintf(buf, sizeof(buf), "%d", idx); + s.index = buf; + s.primaryTag = ParsePrimaryTag(obj); + if (!s.id.empty()) out.push_back(s); + } + return out; +} + +static std::vector FetchEpisodesForSeason(const std::string& seasonId, + const std::string& showId) +{ + char url[1024]; + snprintf(url, sizeof(url), + "%s/Shows/%s/Episodes?seasonId=%s&UserId=%s&Fields=Overview", + g_jellyfinUrl, showId.c_str(), seasonId.c_str(), g_jellyfinUserId); + HttpResponse r = Http_Get(url, JellyfinHeaders(g_jellyfinToken)); + std::vector out; + if (!r.ok()) return out; + size_t arr = Json_FindArray(r.body, "Items"); + if (arr == std::string::npos) return out; + for (const auto& obj : Json_SplitArray(r.body, arr)) { + JellyfinEpisode e; + e.id = Json_GetString(obj, "Id"); + e.name = Json_GetString(obj, "Name"); + e.overview = Json_GetString(obj, "Overview"); + int idx = Json_GetInt(obj, "IndexNumber"); + if (idx > 0) { + char buf[8]; snprintf(buf, sizeof(buf), "%d", idx); + e.index = buf; + } + e.primaryTag = ParsePrimaryTag(obj); + if (!e.id.empty()) out.push_back(e); + } + return out; +} + +// Map from seasonId -> parent showId, populated as we fetch seasons. Lets +// Jellyfin_Cache_GetEpisodes hit the /Shows/{showId}/Episodes endpoint +// with the right show id; we never have to plumb it from the caller. +static std::map s_seasonToShow; + +bool Jellyfin_Cache_GetSeasons(const std::string& showId, + std::vector& out) +{ + { + std::lock_guard lk(g_mtx); + auto it = s_cacheSeasons.find(showId); + if (it != s_cacheSeasons.end()) { + out = it->second; + return true; + } + } + std::vector fetched = FetchSeasonsForShow(showId); + { + std::lock_guard lk(g_mtx); + s_cacheSeasons[showId] = fetched; + for (const auto& s : fetched) s_seasonToShow[s.id] = showId; + out = fetched; + } + return true; +} + +bool Jellyfin_Cache_GetEpisodes(const std::string& seasonId, + std::vector& out) +{ + { + std::lock_guard lk(g_mtx); + auto it = s_cacheEpisodes.find(seasonId); + if (it != s_cacheEpisodes.end()) { + out = it->second; + return true; + } + } + std::string showId; + { + std::lock_guard lk(g_mtx); + auto it = s_seasonToShow.find(seasonId); + if (it != s_seasonToShow.end()) showId = it->second; + } + if (showId.empty()) return false; + std::vector fetched = FetchEpisodesForSeason(seasonId, showId); + { + std::lock_guard lk(g_mtx); + s_cacheEpisodes[seasonId] = fetched; + out = fetched; + } + return true; +} + + +// ============================================================================ +// Stream URL +// ============================================================================ + +static std::string Base() +{ + std::string u = g_jellyfinUrl; + if (!u.empty() && u.find("://") == std::string::npos) u = "http://" + u; + return u; +} + +std::string Jellyfin_StreamUrl(const std::string& itemId) +{ + if (itemId.empty() || g_jellyfinUrl[0] == 0 || g_jellyfinToken[0] == 0) return ""; + std::string out = Base(); + out += "/Videos/"; + out += itemId; + out += "/stream?Static=true&api_key="; + out += g_jellyfinToken; + return out; +} + + +// ============================================================================ +// Cover art +// ============================================================================ + +static inline int Mkdir_(const char* path) { +#ifdef _WIN32 + return mkdir(path); +#else + return mkdir(path, 0755); +#endif +} + +static std::string ArtHostPath(const std::string& itemId) +{ + Mkdir_("Library"); + Mkdir_("Library/Jellyfin"); + return "Library/Jellyfin/" + itemId + ".jpg"; +} + +std::string Jellyfin_ArtCachePath(const std::string& itemId) +{ + return "E:\\Jellyfin\\" + itemId + ".jpg"; +} + +static void ArtWorker(std::string itemId, std::string tag, + std::atomic* flag) +{ + std::string path = ArtHostPath(itemId); + struct stat st; + if (stat(path.c_str(), &st) != 0 || st.st_size == 0) { + char url[1024]; + if (tag.empty()) { + snprintf(url, sizeof(url), + "%s/Items/%s/Images/Primary?fillHeight=600", + g_jellyfinUrl, itemId.c_str()); + } else { + snprintf(url, sizeof(url), + "%s/Items/%s/Images/Primary?fillHeight=600&tag=%s", + g_jellyfinUrl, itemId.c_str(), tag.c_str()); + } + if (!Http_GetToFile(url, path)) { + fprintf(stderr, "[Jellyfin] art fetch failed for %s\n", itemId.c_str()); + } + } + { + std::lock_guard lk(s_artMtx); + s_artInFlight.erase(itemId); + } + delete flag; +} + +void Jellyfin_QueueArtDownload(const std::string& itemId, const std::string& tag) +{ + if (itemId.empty()) return; + { + std::lock_guard lk(s_artMtx); + if (s_artInFlight.count(itemId)) return; + s_artInFlight[itemId] = new std::atomic(false); + } + std::thread(ArtWorker, itemId, tag, s_artInFlight[itemId]).detach(); +} diff --git a/theseus/desktop/jellyfin_client.h b/theseus/desktop/jellyfin_client.h new file mode 100644 index 0000000..02a15e8 --- /dev/null +++ b/theseus/desktop/jellyfin_client.h @@ -0,0 +1,77 @@ +// jellyfin_client.h: Jellyfin Media Server client. Quick Connect auth + browse. + +#pragma once + +#include +#include + +struct JellyfinLibrary { + std::string id; // CollectionFolder ItemId + std::string name; // "Movies", "TV Shows" + std::string type; // "movies" | "tvshows" | "music" | ... +}; + +struct JellyfinItem { + std::string id; + std::string name; + std::string year; // optional, as string + std::string overview; // plot + std::string type; // "Movie" | "Series" | "Audio" | ... + std::string primaryTag; // image tag for Primary image URL +}; + +struct JellyfinSeason { + std::string id; + std::string name; // "Season 1" + std::string index; // season number as string + std::string primaryTag; +}; + +struct JellyfinEpisode { + std::string id; + std::string name; + std::string index; // episode number as string + std::string overview; + std::string primaryTag; +}; + +// Lifecycle +void Jellyfin_Init(); +void Jellyfin_Shutdown(); +bool Jellyfin_HasToken(); +void Jellyfin_SignOut(); + +// Quick Connect: user enters a 6-letter code on the server's web UI. +// Start spawns a poller; UI shows GetCode each frame, HasToken flips true +// once the user approves on the server side. +void Jellyfin_StartQuickConnect(); +void Jellyfin_CancelQuickConnect(); +bool Jellyfin_QuickConnectInFlight(); +std::string Jellyfin_GetQuickConnectCode(); + +// Active server URL (whatever the user set in the Settings tab). +std::string Jellyfin_GetServerUrl(); +void Jellyfin_SetServerUrl(const std::string& url); + +// User-friendly auth state for the Settings tab. +std::string Jellyfin_GetUserName(); // empty until token acquired + +// Pre-stage cache. Walks libraries + items on sign-in; seasons + episodes +// stay lazy with permanent in-session caching (same shape as Plex). +void Jellyfin_StartSync(); +bool Jellyfin_SyncReady(); +std::string Jellyfin_SyncPhase(); +int Jellyfin_SyncProgress(); // 0..1000 per-mille + +std::vector Jellyfin_Cache_GetLibraries(); +std::vector Jellyfin_Cache_GetItems(const std::string& libraryId); +bool Jellyfin_Cache_GetSeasons (const std::string& showId, std::vector& out); +bool Jellyfin_Cache_GetEpisodes(const std::string& seasonId, std::vector& out); + +// Direct-play URL with api_key. Empty on failure. +std::string Jellyfin_StreamUrl(const std::string& itemId); + +// Cover art. Same E:\Jellyfin\.jpg trick as Plex. +std::string Jellyfin_ArtCachePath(const std::string& itemId); +void Jellyfin_QueueArtDownload(const std::string& itemId, + const std::string& primaryTag); diff --git a/theseus/desktop/materials_desktop.cpp b/theseus/desktop/materials_desktop.cpp index f156538..2ef11d3 100755 --- a/theseus/desktop/materials_desktop.cpp +++ b/theseus/desktop/materials_desktop.cpp @@ -1027,6 +1027,57 @@ static void UpdateFalloffColor(CMatInfo *p, D3DCOLOR colorSide, D3DCOLOR colorFr pMat->m_colorFront = colorFront; } +// Public introspection / live edit API used by the Skin Editor. +int MatInfo_ColorCount(CMatInfo* p) +{ + if (!p) return 0; + if (dynamic_cast(p)) return 2; + if (dynamic_cast(p)) return 2; + if (dynamic_cast(p)) return 1; + if (dynamic_cast(p)) return 1; + if (dynamic_cast(p)) return 1; + return 0; +} + +DWORD MatInfo_GetColor(CMatInfo* p, int idx) +{ + if (!p) return 0; + if (CSolidMatInfo* m = dynamic_cast(p)) + return D3DCOLOR_RGBA(m->m_r, m->m_g, m->m_b, m->m_a); + if (CBackingMatInfo* m = dynamic_cast(p)) + return D3DCOLOR_RGBA(m->m_r, m->m_g, m->m_b, m->m_a); + if (CModulateTextureMatInfo* m = dynamic_cast(p)) + return D3DCOLOR_RGBA(m->m_r, m->m_g, m->m_b, m->m_a); + if (CInnerWallMatInfo* m = dynamic_cast(p)) + return (idx == 0) ? m->m_colorSide : m->m_colorFront; + if (CFalloffMatInfo* m = dynamic_cast(p)) + return (idx == 0) ? m->m_colorSide : m->m_colorFront; + return 0; +} + +void MatInfo_SetColor(CMatInfo* p, int idx, DWORD c) +{ + if (!p) return; + BYTE a = (BYTE)((c >> 24) & 0xFF); + BYTE r = (BYTE)((c >> 16) & 0xFF); + BYTE g = (BYTE)((c >> 8) & 0xFF); + BYTE b = (BYTE)( c & 0xFF); + if (CSolidMatInfo* m = dynamic_cast(p)) + { m->m_r=r; m->m_g=g; m->m_b=b; m->m_a=a; return; } + if (CBackingMatInfo* m = dynamic_cast(p)) + { m->m_r=r; m->m_g=g; m->m_b=b; m->m_a=a; return; } + if (CModulateTextureMatInfo* m = dynamic_cast(p)) + { m->m_r=r; m->m_g=g; m->m_b=b; m->m_a=a; return; } + if (CInnerWallMatInfo* m = dynamic_cast(p)) { + if (idx == 0) m->m_colorSide = c; else m->m_colorFront = c; + return; + } + if (CFalloffMatInfo* m = dynamic_cast(p)) { + if (idx == 0) m->m_colorSide = c; else m->m_colorFront = c; + return; + } +} + // Reads a single-color material from the skin file and updates in place static void ReloadSolidMat(CSettingsFile &SkinXBX, const TCHAR *szSection, CustomColor *tmp) { @@ -1914,4 +1965,9 @@ void CMaxMaterial::Render() } m_pMatInfo->Setup(this); + +#ifdef _DESKTOP + extern IDirect3DDevice8* g_pD3DDev; + if (g_pD3DDev) m_pMatInfo->m_lastUsedFrame = g_pD3DDev->m_frameNumber; +#endif } diff --git a/theseus/desktop/media_player.cpp b/theseus/desktop/media_player.cpp index 4aa54f2..b3a368c 100644 --- a/theseus/desktop/media_player.cpp +++ b/theseus/desktop/media_player.cpp @@ -256,6 +256,12 @@ bool MediaPlayer_Open(const char* path) { const char* cmd[] = { "loadfile", path, NULL }; mpv_command(s_mpv, cmd); + // Apply master volume to the new playback session. mpv's volume property + // takes 0-100, our slider is 0.0-1.0. + extern float g_masterVolume; + double mpvVol = (double)(g_masterVolume * 100.0f); + mpv_set_property(s_mpv, "volume", MPV_FORMAT_DOUBLE, &mpvVol); + s_state = MP_PLAYING; s_position = 0.0; s_duration = 0.0; @@ -266,6 +272,14 @@ bool MediaPlayer_Open(const char* path) { return true; } +void MediaPlayer_SetMasterVolume(float vol) { + if (vol < 0.0f) vol = 0.0f; + if (vol > 1.0f) vol = 1.0f; + if (!s_mpv) return; + double mpvVol = (double)(vol * 100.0f); + mpv_set_property(s_mpv, "volume", MPV_FORMAT_DOUBLE, &mpvVol); +} + void MediaPlayer_Play() { if (!s_mpv) return; mpv_set_property_string(s_mpv, "pause", "no"); diff --git a/theseus/desktop/media_player.h b/theseus/desktop/media_player.h index 3efe180..e3dd969 100644 --- a/theseus/desktop/media_player.h +++ b/theseus/desktop/media_player.h @@ -102,3 +102,7 @@ int MediaPlayer_GetAudioFormat(); // 0=AC3, 1=MPEG1, 2=MPEG2, 3=PCM/S const char* MediaPlayer_GetAudioLanguage(); // current audio track language const char* MediaPlayer_GetSubtitleLanguage(); // current subtitle track language int MediaPlayer_GetSubtitleTrack(); // 0 = off, >0 = track index + +// Master volume (0.0 - 1.0). Applies immediately if a session is live and +// is re-applied on every Open so subsequent playbacks inherit the value. +void MediaPlayer_SetMasterVolume(float vol); diff --git a/theseus/desktop/media_ui.cpp b/theseus/desktop/media_ui.cpp index 0a947a8..3ffc223 100644 --- a/theseus/desktop/media_ui.cpp +++ b/theseus/desktop/media_ui.cpp @@ -10,6 +10,7 @@ #include "audio_sdl.h" #include "imgui.h" #include "playlist.h" +#include "d3d8_sdl.h" // for g_bgfxProgBlit / g_bgfxSamplerBlit on bgfx path #include #include @@ -222,10 +223,68 @@ void MediaUI_DrawFullscreenVideo() } } #else - // CRT post-process is gated under BGFX (chunk 5d-3+ work). The - // fullscreen video display happens via MediaPlayer_RenderToScreen - // from CDVDPlayer::Render. nothing for us to do here. - (void)boundFBO; (void)ww; (void)wh; + // Submit the mpv frame as an aspect-fit quad on view 0. View 0 is + // pointed at the CRT capture FBO (when CRT is on) or the backbuffer + // (when off) by sdl_main.cpp's caller, so this lands exactly where + // the CRT pass on view 1 will pick it up. View 0's clear paints the + // letterbox region; without it the previous frame's pixels show + // through outside the aspect-fit rect. + (void)boundFBO; + + int vw = 0, vh = 0; + unsigned int texIdx = MediaPlayer_GetVideoTexture(&vw, &vh); + if (!bgfx::isValid(g_bgfxProgBlit) || texIdx == 0 || vw <= 0 || vh <= 0) return; + bgfx::TextureHandle vidTex; vidTex.idx = (uint16_t)texIdx; + if (!bgfx::isValid(vidTex)) return; + + int dw = (ww > 0) ? ww : 1; + int dh = (wh > 0) ? wh : 1; + + bgfx::setViewRect(0, 0, 0, (uint16_t)dw, (uint16_t)dh); + bgfx::setViewClear(0, BGFX_CLEAR_COLOR | BGFX_CLEAR_DEPTH, 0x000000ff, 1.0f, 0); + + float vAspect = (float)vw / (float)vh; + float wAspect = (float)dw / (float)dh; + float qw, qh; // half-extents in NDC + if (vAspect > wAspect) { qw = 1.0f; qh = wAspect / vAspect; } + else { qh = 1.0f; qw = vAspect / wAspect; } + + bgfx::VertexLayout layout; + layout.begin() + .add(bgfx::Attrib::Position, 3, bgfx::AttribType::Float) + .add(bgfx::Attrib::Normal, 3, bgfx::AttribType::Float) + .add(bgfx::Attrib::Color0, 4, bgfx::AttribType::Uint8, true) + .add(bgfx::Attrib::Color1, 4, bgfx::AttribType::Uint8, true) + .add(bgfx::Attrib::TexCoord0, 2, bgfx::AttribType::Float) + .end(); + + struct V { float px, py, pz, nx, ny, nz; uint32_t c0, c1; float u, v; }; + // Texture is written top-down (row 0 = visual top). NDC +1 Y = top + // of screen, so the top edge of the quad samples V=0. Matches the + // boot_anim blit's mapping. + V verts[4] = { + { -qw, -qh, 0.f, 0,0,0, 0,0, 0.f, 1.f }, + { qw, -qh, 0.f, 0,0,0, 0,0, 1.f, 1.f }, + { qw, qh, 0.f, 0,0,0, 0,0, 1.f, 0.f }, + { -qw, qh, 0.f, 0,0,0, 0,0, 0.f, 0.f }, + }; + const uint16_t idx[6] = { 0, 1, 2, 0, 2, 3 }; + + bgfx::TransientVertexBuffer tvb; + bgfx::TransientIndexBuffer tib; + if (bgfx::getAvailTransientVertexBuffer(4, layout) < 4) return; + if (bgfx::getAvailTransientIndexBuffer(6) < 6) return; + bgfx::allocTransientVertexBuffer(&tvb, 4, layout); + memcpy(tvb.data, verts, sizeof(verts)); + bgfx::allocTransientIndexBuffer(&tib, 6); + memcpy(tib.data, idx, sizeof(idx)); + + bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A); + bgfx::setTexture(0, g_bgfxSamplerBlit, vidTex); + bgfx::setVertexBuffer(0, &tvb, 0, 4); + bgfx::setIndexBuffer(&tib, 0, 6); + bgfx::submit(0, g_bgfxProgBlit); + s_videoBlittedToFBO = true; #endif } @@ -343,7 +402,7 @@ void MediaUI_RenderOSD() // Top gradient, Xbox green tint, fades to transparent. { const float h = 96.0f; - ImDrawList* dl = ImGui::GetForegroundDrawList(); + ImDrawList* dl = ImGui::GetBackgroundDrawList(); ImU32 top = RGBA(0.02f, 0.10f, 0.04f, 0.85f * alpha); ImU32 bottom = RGBA(0.02f, 0.10f, 0.04f, 0.00f); dl->AddRectFilledMultiColor( @@ -369,7 +428,7 @@ void MediaUI_RenderOSD() // Bottom gradient, fades up from black; transport sits on it. { const float h = 110.0f; - ImDrawList* dl = ImGui::GetForegroundDrawList(); + ImDrawList* dl = ImGui::GetBackgroundDrawList(); ImU32 top = RGBA(0.02f, 0.10f, 0.04f, 0.00f); ImU32 bottom = RGBA(0.02f, 0.10f, 0.04f, 0.85f * alpha); float y0 = (float)wh - h; diff --git a/theseus/desktop/menu_bar.cpp b/theseus/desktop/menu_bar.cpp index 7976084..27d4d43 100644 --- a/theseus/desktop/menu_bar.cpp +++ b/theseus/desktop/menu_bar.cpp @@ -1,7 +1,5 @@ -// menu_bar.cpp: desktop top-level ImGui menu bar. Drives the -// Settings, About, and Shortcuts windows. Features (Title Maker, -// HDD Browser, Media) are always accessible; dev tools (Inspector, -// XAP Editor) only appear in Development Mode. Desktop-only. +// menu_bar.cpp: desktop top-level ImGui menu bar + Settings/About/Shortcuts +// windows. The Development menu only renders in Development Mode. #include "std.h" #include "dashapp.h" @@ -9,9 +7,15 @@ #include "title_maker.h" #include "hdd_browser.h" #include "audio_sdl.h" +#include "skin_editor.h" #include "imgui.h" +#include "plex_client.h" +#include "jellyfin_client.h" +#include "media_player.h" +#include "milkdrop_window.h" -extern bool g_bWireframe; +extern bool g_bWireframe; +extern float g_masterVolume; #include #include @@ -41,16 +45,17 @@ extern char g_tmdbKey[128]; // Internal State // ============================================================================ -static bool s_settingsOpen = false; +bool g_settingsOpen = false; static bool s_aboutOpen = false; static bool s_shortcutsOpen = false; +bool g_projectMConfigOpen = false; // Old "Open Media..." file-browser removed. Playback now flows through // the Media Library (CMediaCollection.PlayMovie/PlayEpisode -> MediaUI // fullscreen). The legacy CDVDPlayer XAP scene was painful to wire up // and is no longer the desktop's playback path. -void ToggleSettingsWindow() { s_settingsOpen = !s_settingsOpen; } +void ToggleSettingsWindow() { g_settingsOpen = !g_settingsOpen; } // ============================================================================ // CRT Presets @@ -141,7 +146,7 @@ void RenderMainMenuBar() { } ImGui::Separator(); if (ImGui::MenuItem("Settings...", "F4")) { - s_settingsOpen = true; + g_settingsOpen = true; } extern bool g_showMenuBar; if (ImGui::MenuItem("Hide Menu Bar", "F10")) { @@ -156,7 +161,7 @@ void RenderMainMenuBar() { ImGui::EndMenu(); } - // ---- Tools (features always visible, dev tools gated) ---- + // ---- Tools (shipping tools only; dev tools live under Development) ---- if (ImGui::BeginMenu("Tools")) { if (ImGui::MenuItem("Title Maker", "F3", g_titleMakerOpen)) { g_titleMakerOpen = !g_titleMakerOpen; @@ -168,11 +173,63 @@ void RenderMainMenuBar() { if (ImGui::MenuItem("Playlist Maker", "F6", g_playlistMakerOpen)) { g_playlistMakerOpen = !g_playlistMakerOpen; } + if (ImGui::MenuItem("Skin Editor", NULL, g_skinEditorOpen)) { + g_skinEditorOpen = !g_skinEditorOpen; + } + ImGui::EndMenu(); + } - // Dev tools only in Development Mode - if (g_startupMode == 2 || g_extractedMode) { - ImGui::Separator(); - ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "Development"); + // ---- View (display only) ---- + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("CRT Effect", NULL, g_crt.enabled)) { + g_crt.enabled = !g_crt.enabled; + SaveDesktopSettings(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Wireframe", NULL, g_bWireframe)) { + g_bWireframe = !g_bWireframe; + } + if (ImGui::BeginMenu("MSAA")) { + for (int i = 0; i < s_msaaCount; i++) { + if (ImGui::MenuItem(s_msaaLabels[i], NULL, g_msaaSamples == s_msaaValues[i])) { + g_msaaSamples = s_msaaValues[i]; + g_msaaChangeRequested = true; + } + } + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + + // ---- Audio ---- + if (ImGui::BeginMenu("Audio")) { + if (ImGui::MenuItem("Mute", "Ctrl+M", g_audioMuted)) { + g_audioMuted = !g_audioMuted; + if (g_audioMuted) DashAudio_MuteAll(); + else DashAudio_UnmuteAll(); + g_muteOverlayTimer = 2.0f; + } + ImGui::Separator(); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Volume"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(180.0f); + float volPct = g_masterVolume * 100.0f; + if (ImGui::SliderFloat("##mastervol", &volPct, 0.0f, 100.0f, "%.0f%%", + ImGuiSliderFlags_AlwaysClamp)) { + float v = volPct / 100.0f; + DashAudio_SetMasterVolume(v); + MediaPlayer_SetMasterVolume(v); + } + if (ImGui::IsItemDeactivatedAfterEdit()) { + SaveDesktopSettings(); + } + ImGui::EndMenu(); + } + + // ---- Development (dev-mode only) ---- + if (g_startupMode == 2 || g_extractedMode) { + if (ImGui::BeginMenu("Development")) { if (ImGui::MenuItem("Inspector", "F1", g_debugMode)) { g_debugMode = !g_debugMode; g_inspectorOpen = g_debugMode; @@ -197,37 +254,8 @@ void RenderMainMenuBar() { else DestroyXapEditorWindow(); } - } - ImGui::EndMenu(); - } - - // ---- View ---- - if (ImGui::BeginMenu("View")) { - if (ImGui::MenuItem("CRT Effect", NULL, g_crt.enabled)) { - g_crt.enabled = !g_crt.enabled; - SaveDesktopSettings(); - } - ImGui::Separator(); - if (ImGui::MenuItem("Mute Audio", "Ctrl+M", g_audioMuted)) { - g_audioMuted = !g_audioMuted; - if (g_audioMuted) DashAudio_MuteAll(); - else DashAudio_UnmuteAll(); - g_muteOverlayTimer = 2.0f; - } - ImGui::Separator(); - if (ImGui::MenuItem("Wireframe", NULL, g_bWireframe)) { - g_bWireframe = !g_bWireframe; - } - if (ImGui::BeginMenu("MSAA")) { - for (int i = 0; i < s_msaaCount; i++) { - if (ImGui::MenuItem(s_msaaLabels[i], NULL, g_msaaSamples == s_msaaValues[i])) { - g_msaaSamples = s_msaaValues[i]; - g_msaaChangeRequested = true; - } - } ImGui::EndMenu(); } - ImGui::EndMenu(); } // ---- Help ---- @@ -250,12 +278,12 @@ void RenderMainMenuBar() { // ============================================================================ void RenderSettingsWindow() { - if (!s_settingsOpen) return; + if (!g_settingsOpen) return; ImGui::SetNextWindowSize(ImVec2(540, 0), ImGuiCond_Appearing); ImGui::SetNextWindowSizeConstraints(ImVec2(540, 0), ImVec2(540, 800)); ImGuiWindowFlags settingsFlags = ImGuiWindowFlags_AlwaysAutoResize; - if (!ImGui::Begin("Settings", &s_settingsOpen, settingsFlags)) { + if (!ImGui::Begin("Settings", &g_settingsOpen, settingsFlags)) { ImGui::End(); return; } @@ -436,8 +464,70 @@ void RenderSettingsWindow() { ImGui::EndTabItem(); } + // ---- Audio ---- + if (ImGui::BeginTabItem("Audio")) { + ImGui::Spacing(); + + if (ImGui::Checkbox("Mute", &g_audioMuted)) { + if (g_audioMuted) DashAudio_MuteAll(); + else DashAudio_UnmuteAll(); + g_muteOverlayTimer = 2.0f; + SaveDesktopSettings(); + } + + ImGui::Spacing(); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Master Volume"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(220.0f); + float volPct = g_masterVolume * 100.0f; + if (ImGui::SliderFloat("##settingsmastervol", &volPct, + 0.0f, 100.0f, "%.0f%%", + ImGuiSliderFlags_AlwaysClamp)) { + float v = volPct / 100.0f; + DashAudio_SetMasterVolume(v); + MediaPlayer_SetMasterVolume(v); + } + if (ImGui::IsItemDeactivatedAfterEdit()) SaveDesktopSettings(); + + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Scales dashboard sounds, music, and media\n" + "playback. Mute overrides this until cleared."); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + extern bool g_useMilkdropViz; + if (ImGui::Checkbox("Use projectM visualizer (experimental)", + &g_useMilkdropViz)) { + SaveDesktopSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Replace the legacy music-scene visualizer with the\n" + "projectM (MilkDrop) renderer. X+Y on the music scene\n" + "fullscreens it once enabled."); + } + + extern bool g_projectMConfigOpen; + if (ImGui::Button("Configure projectM...")) { + g_projectMConfigOpen = true; + } + + ImGui::EndTabItem(); + } + // ---- Media Library ---- - if (ImGui::BeginTabItem("Media Library")) { + if (ImGui::BeginTabItem("Media")) { + ImGui::Spacing(); + + if (ImGui::CollapsingHeader("Local Library", ImGuiTreeNodeFlags_DefaultOpen)) { ImGui::Spacing(); ImGui::TextWrapped("Configure where the dashboard scans for media. Empty values fall back to the bundled defaults shown in placeholder text."); ImGui::Spacing(); @@ -535,6 +625,162 @@ void RenderSettingsWindow() { if (ImGui::Button("Save Library Paths")) SaveDesktopSettings(); + } // Local Library header + + if (ImGui::CollapsingHeader("Plex")) { + ImGui::Spacing(); + ImGui::TextWrapped( + "Sign in to your Plex account to browse your libraries from the " + "Media -> Plex scene."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (Plex_HasToken()) { + Plex_StartSync(); + + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.55f, 1.0f), + "Signed in."); + ImGui::Spacing(); + + if (!Plex_SyncReady()) { + ImGui::Text("%s", Plex_SyncPhase().c_str()); + float frac = Plex_SyncProgress() / 1000.0f; + ImGui::ProgressBar(frac, ImVec2(-1, 6.0f), ""); + ImGui::Spacing(); + } else { + ImGui::TextDisabled("Library ready (%d libraries).", + (int)Plex_Cache_GetLibraries().size()); + ImGui::Spacing(); + } + + if (ImGui::Button("Sign out")) { + Plex_SignOut(); + SaveDesktopSettings(); + } + } else if (Plex_PinAuthInFlight()) { + std::string code = Plex_GetPinCode(); + if (code.empty()) { + ImGui::Text("Requesting code..."); + } else { + ImGui::Text("1. Open"); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.55f, 0.85f, 1.0f, 1.0f), + "plex.tv/link"); + ImGui::SameLine(); + ImGui::Text("on any device."); + ImGui::Text("2. Enter this code:"); + ImGui::Spacing(); + + // Big, centered code display. + ImFont* font = ImGui::GetFont(); + float old = font->Scale; + font->Scale = 3.0f; + ImGui::PushFont(font); + ImVec2 sz = ImGui::CalcTextSize(code.c_str()); + float avail = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + + (avail - sz.x) * 0.5f); + ImGui::TextColored(ImVec4(0.85f, 1.0f, 0.85f, 1.0f), + "%s", code.c_str()); + ImGui::PopFont(); + font->Scale = old; + } + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + if (ImGui::Button("Cancel")) Plex_CancelPinAuth(); + } else { + ImGui::TextDisabled("Not signed in."); + ImGui::Spacing(); + if (ImGui::Button("Sign in to Plex")) Plex_StartPinAuth(); + } + } // Plex header + + if (ImGui::CollapsingHeader("Jellyfin")) { + ImGui::Spacing(); + ImGui::TextWrapped( + "Sign in to your Jellyfin server. Enter the server URL, then " + "use Quick Connect to approve the 6-letter code on the server's " + "web UI."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + static char s_urlBuf[512]; + static bool s_urlInit = false; + if (!s_urlInit) { + std::string cur = Jellyfin_GetServerUrl(); + strncpy(s_urlBuf, cur.c_str(), sizeof(s_urlBuf) - 1); + s_urlBuf[sizeof(s_urlBuf) - 1] = 0; + s_urlInit = true; + } + ImGui::Text("Server URL"); + ImGui::PushItemWidth(-1); + if (ImGui::InputText("##jellyurl", s_urlBuf, sizeof(s_urlBuf))) { + Jellyfin_SetServerUrl(s_urlBuf); + } + ImGui::PopItemWidth(); + ImGui::Spacing(); + + if (Jellyfin_HasToken()) { + Jellyfin_StartSync(); + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.55f, 1.0f), + "Signed in as %s.", Jellyfin_GetUserName().c_str()); + ImGui::Spacing(); + + if (!Jellyfin_SyncReady()) { + ImGui::Text("%s", Jellyfin_SyncPhase().c_str()); + float frac = Jellyfin_SyncProgress() / 1000.0f; + ImGui::ProgressBar(frac, ImVec2(-1, 6.0f), ""); + ImGui::Spacing(); + } else { + ImGui::TextDisabled("Library ready (%d libraries).", + (int)Jellyfin_Cache_GetLibraries().size()); + ImGui::Spacing(); + } + + if (ImGui::Button("Sign out")) { + Jellyfin_SignOut(); + SaveDesktopSettings(); + } + } else if (Jellyfin_QuickConnectInFlight()) { + std::string code = Jellyfin_GetQuickConnectCode(); + if (code.empty()) { + ImGui::Text("Requesting code..."); + } else { + ImGui::Text("1. Open the Jellyfin web UI on any device."); + ImGui::Text("2. Go to your account -> Quick Connect."); + ImGui::Text("3. Enter this code:"); + ImGui::Spacing(); + + ImFont* font = ImGui::GetFont(); + float old = font->Scale; + font->Scale = 3.0f; + ImGui::PushFont(font); + ImVec2 sz = ImGui::CalcTextSize(code.c_str()); + float avail = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + + (avail - sz.x) * 0.5f); + ImGui::TextColored(ImVec4(0.85f, 1.0f, 0.85f, 1.0f), + "%s", code.c_str()); + ImGui::PopFont(); + font->Scale = old; + } + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + if (ImGui::Button("Cancel")) Jellyfin_CancelQuickConnect(); + } else { + ImGui::TextDisabled("Not signed in."); + ImGui::Spacing(); + if (s_urlBuf[0] == 0) { + ImGui::TextDisabled("Set a server URL first."); + } else if (ImGui::Button("Sign in with Quick Connect")) { + Jellyfin_StartQuickConnect(); + } + } + } // Jellyfin header ImGui::EndTabItem(); } @@ -675,3 +921,190 @@ void RenderShortcutsWindow() { // No floating panel. Keep this stub so sdl_main.cpp's PreSwap call is a no-op. bool s_scanPanelVisible = false; void RenderScanProgressModal() {} + +// ============================================================================ +// projectM Configuration Window +// ============================================================================ + +void RenderProjectMConfig() { + extern bool g_useMilkdropViz; + static bool s_previewOn = false; + if (!g_projectMConfigOpen) { + if (s_previewOn) { + MilkdropWindow_SetPreviewVisible(false); + s_previewOn = false; + } + return; + } + + ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_Always); + if (!ImGui::Begin("Configure projectM", &g_projectMConfigOpen, + ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoResize)) { + ImGui::End(); + return; + } + + if (!g_useMilkdropViz) { + ImGui::TextWrapped( + "projectM is disabled. Enable it in the Audio tab first."); + ImGui::End(); + return; + } + + // Session controls: lets the user spin projectM up / down without + // hunting for the X+Y combo. + if (MilkdropWindow_IsOpen()) { + if (ImGui::Button("Stop session", ImVec2(120, 0))) + MilkdropWindow_Toggle(); + } else { + if (ImGui::Button("Start session", ImVec2(120, 0))) + MilkdropWindow_Toggle(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(or hit X+Y)"); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (!MilkdropWindow_IsOpen()) ImGui::BeginDisabled(); + + if (ImGui::Button("< Prev", ImVec2(80, 0))) + MilkdropWindow_PreviousPreset(); + ImGui::SameLine(); + if (ImGui::Button("Next >", ImVec2(80, 0))) + MilkdropWindow_NextPreset(); + ImGui::SameLine(); + bool locked = MilkdropWindow_GetPresetLocked(); + if (ImGui::Checkbox("Lock", &locked)) + MilkdropWindow_SetPresetLocked(locked); + + ImGui::Spacing(); + int presetCount = MilkdropWindow_GetPresetCount(); + int curPreset = MilkdropWindow_GetCurrentPresetIndex(); + const char* curName = (curPreset >= 0 && curPreset < presetCount) + ? MilkdropWindow_GetPresetName(curPreset) : ""; + ImGui::SetNextItemWidth(-1); + if (ImGui::BeginCombo("##presetpicker", curName)) { + // Filter + static char s_presetFilter[64] = ""; + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##presetfilter", "Filter...", + s_presetFilter, sizeof(s_presetFilter)); + + ImGui::BeginChild("PresetList", ImVec2(0, 240), false); + for (int i = 0; i < presetCount; i++) { + const char* nm = MilkdropWindow_GetPresetName(i); + if (!nm || !*nm) continue; + if (s_presetFilter[0]) { + bool match = false; + for (const char* p = nm; *p; p++) { + const char* a = p; const char* b = s_presetFilter; + while (*a && *b && (tolower(*a) == tolower(*b))) { a++; b++; } + if (!*b) { match = true; break; } + } + if (!match) continue; + } + bool sel = (i == curPreset); + char label[256]; + snprintf(label, sizeof(label), "%s##p%d", nm, i); + if (ImGui::Selectable(label, sel)) + MilkdropWindow_SetPresetIndex(i); + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndChild(); + ImGui::EndCombo(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + const float kLabelX = 130.0f; + const float kSliderW = 200.0f; + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Beat sensitivity"); + ImGui::SameLine(kLabelX); + ImGui::SetNextItemWidth(kSliderW); + float sens = MilkdropWindow_GetBeatSensitivity(); + if (ImGui::SliderFloat("##sens", &sens, 0.0f, 2.0f, "%.2f")) + MilkdropWindow_SetBeatSensitivity(sens); + + ImGui::AlignTextToFramePadding(); + ImGui::Text("Preset duration"); + ImGui::SameLine(kLabelX); + ImGui::SetNextItemWidth(kSliderW); + float durf = (float)MilkdropWindow_GetPresetDuration(); + if (ImGui::SliderFloat("##dur", &durf, 1.0f, 60.0f, "%.0fs")) + MilkdropWindow_SetPresetDuration((double)durf); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Checkbox("Show preview window", &s_previewOn)) + MilkdropWindow_SetPreviewVisible(s_previewOn); + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Separate window showing the raw projectM render.\n" + "Independent from the fullscreen overlay in the dashboard."); + } + + if (!MilkdropWindow_IsOpen()) ImGui::EndDisabled(); + + // ---------- Track picker for previewing without the music scene ---------- + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + ImGui::Text("Test audio"); + + int stCount = DashMusic_GetSoundtrackCount(); + static int s_stIdx = 0; + if (s_stIdx >= stCount) s_stIdx = 0; + const char* stName = (stCount > 0) ? DashMusic_GetSoundtrackName(s_stIdx) : "(none)"; + + ImGui::SetNextItemWidth(-1); + if (ImGui::BeginCombo("##soundtrack", stName ? stName : "(none)")) { + for (int i = 0; i < stCount; i++) { + const char* nm = DashMusic_GetSoundtrackName(i); + bool sel = (i == s_stIdx); + if (ImGui::Selectable(nm ? nm : "(?)", sel)) s_stIdx = i; + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + int songCount = (stCount > 0) ? DashMusic_GetSongCount(s_stIdx) : 0; + static int s_songIdx = 0; + if (s_songIdx >= songCount) s_songIdx = 0; + + ImGui::BeginChild("ProjectMSongs", ImVec2(0, 140), true); + for (int i = 0; i < songCount; i++) { + const char* nm = DashMusic_GetSongName(s_stIdx, i); + bool sel = (i == s_songIdx); + char label[128]; + snprintf(label, sizeof(label), "%s##s%d", nm ? nm : "(?)", i); + if (ImGui::Selectable(label, sel)) + s_songIdx = i; + } + ImGui::EndChild(); + + bool playing = DashAudio_IsMusicPlaying() != 0; + if (ImGui::Button(playing ? "Stop" : "Play", ImVec2(80, 0))) { + if (playing) { + DashAudio_StopMusic(0); + } else if (songCount > 0) { + const char* path = DashMusic_GetSongPath(s_stIdx, s_songIdx); + if (path && *path) { + if (DashAudio_LoadMusic(path) == 0) + DashAudio_PlayMusic(0, 0); + } + } + } + + ImGui::End(); +} diff --git a/theseus/desktop/milkdrop_window.cpp b/theseus/desktop/milkdrop_window.cpp new file mode 100644 index 0000000..a872970 --- /dev/null +++ b/theseus/desktop/milkdrop_window.cpp @@ -0,0 +1,500 @@ +// milkdrop_window.cpp +// +// libprojectM in a second SDL window. Toggled with X+Y; press it again to +// dismiss. Keeps its own GL context so it never interferes with the main +// dashboard window (which is bgfx-backed on the desktop targets). + +#include "milkdrop_window.h" + +#ifndef THESEUS_HAS_MILKDROP + +bool MilkdropWindow_IsOpen() { return false; } +void MilkdropWindow_Toggle() {} +void MilkdropWindow_Tick() {} +void MilkdropWindow_Shutdown() {} +unsigned int MilkdropWindow_GetWindowID() { return 0; } +void MilkdropWindow_ToggleFullscreen() {} +unsigned short MilkdropWindow_GetBgfxTexId() { return 0xFFFF; } +int MilkdropWindow_GetTexW() { return 0; } +int MilkdropWindow_GetTexH() { return 0; } +const unsigned char* MilkdropWindow_GetReadbackRGBA(int*, int*) { return nullptr; } +void MilkdropWindow_SetPreviewVisible(bool) {} +void MilkdropWindow_NextPreset() {} +void MilkdropWindow_PreviousPreset() {} +float MilkdropWindow_GetBeatSensitivity() { return 1.0f; } +void MilkdropWindow_SetBeatSensitivity(float) {} +double MilkdropWindow_GetPresetDuration() { return 22.0; } +void MilkdropWindow_SetPresetDuration(double) {} +bool MilkdropWindow_GetPresetLocked() { return false; } +void MilkdropWindow_SetPresetLocked(bool) {} +int MilkdropWindow_GetPresetCount() { return 0; } +const char* MilkdropWindow_GetPresetName(int) { return ""; } +int MilkdropWindow_GetCurrentPresetIndex() { return 0; } +void MilkdropWindow_SetPresetIndex(int) {} + +#else // THESEUS_HAS_MILKDROP + +#include +#include +#include +#include +#include +#include +#include "audio_sdl.h" + +#include +#include +#include +#include +#include + +// GL 3.0+ entry points used here. macOS ships them via , but +// SDL_opengl.h already pulls those in on platforms with a system GL. +// We resolve them at runtime via SDL_GL_GetProcAddress to avoid linking gotchas. +typedef void (APIENTRY *PFNGENFBO)(GLsizei, GLuint*); +typedef void (APIENTRY *PFNBINDFBO)(GLenum, GLuint); +typedef void (APIENTRY *PFNFBOTEX2D)(GLenum, GLenum, GLenum, GLuint, GLint); +typedef GLenum (APIENTRY *PFNFBOSTATUS)(GLenum); +typedef void (APIENTRY *PFNDELFBO)(GLsizei, const GLuint*); +typedef void (APIENTRY *PFNBLITFB)(GLint,GLint,GLint,GLint,GLint,GLint,GLint,GLint,GLbitfield,GLenum); + +namespace { + +SDL_Window* s_win = nullptr; +SDL_GLContext s_glc = nullptr; +projectm_handle s_pM = nullptr; +projectm_playlist_handle s_playlist = nullptr; +bool s_open = false; + +// Offscreen target projectM renders into. Sized to a power of two so bgfx +// can sample it cleanly later (Phase B). The visible window blits from here +// during Phase A; Phase B switches to glReadPixels + bgfx upload. +GLuint s_fbo = 0; +GLuint s_fboTex = 0; +const int kFboW = 1024; +const int kFboH = 1024; + +// Phase B: readback + bgfx texture exposed to the scene. +bgfx::TextureHandle s_bgfxTex = BGFX_INVALID_HANDLE; +uint8_t* s_readback = nullptr; + +// GL function pointers resolved at OpenWindow time. +PFNGENFBO s_glGenFramebuffers = nullptr; +PFNBINDFBO s_glBindFramebuffer = nullptr; +PFNFBOTEX2D s_glFramebufferTexture2D = nullptr; +PFNFBOSTATUS s_glCheckFramebufferStatus = nullptr; +PFNDELFBO s_glDeleteFramebuffers = nullptr; +PFNBLITFB s_glBlitFramebuffer = nullptr; + +bool LoadGLEntries() { + s_glGenFramebuffers = (PFNGENFBO) SDL_GL_GetProcAddress("glGenFramebuffers"); + s_glBindFramebuffer = (PFNBINDFBO) SDL_GL_GetProcAddress("glBindFramebuffer"); + s_glFramebufferTexture2D = (PFNFBOTEX2D) SDL_GL_GetProcAddress("glFramebufferTexture2D"); + s_glCheckFramebufferStatus = (PFNFBOSTATUS)SDL_GL_GetProcAddress("glCheckFramebufferStatus"); + s_glDeleteFramebuffers = (PFNDELFBO) SDL_GL_GetProcAddress("glDeleteFramebuffers"); + s_glBlitFramebuffer = (PFNBLITFB) SDL_GL_GetProcAddress("glBlitFramebuffer"); + return s_glGenFramebuffers && s_glBindFramebuffer && s_glFramebufferTexture2D && + s_glCheckFramebufferStatus && s_glDeleteFramebuffers && s_glBlitFramebuffer; +} + +bool CreateOffscreenTarget() { + s_glGenFramebuffers(1, &s_fbo); + glGenTextures(1, &s_fboTex); + + glBindTexture(GL_TEXTURE_2D, s_fboTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kFboW, kFboH, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + + s_glBindFramebuffer(GL_FRAMEBUFFER, s_fbo); + s_glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, s_fboTex, 0); + GLenum st = s_glCheckFramebufferStatus(GL_FRAMEBUFFER); + s_glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (st != GL_FRAMEBUFFER_COMPLETE) { + fprintf(stderr, "[Milkdrop] FBO incomplete: 0x%X\n", (unsigned)st); + return false; + } + return true; +} + +void DestroyOffscreenTarget() { + if (s_fboTex) { glDeleteTextures(1, &s_fboTex); s_fboTex = 0; } + if (s_fbo && s_glDeleteFramebuffers) { s_glDeleteFramebuffers(1, &s_fbo); s_fbo = 0; } +} + +// Preset-name cache for the dropdown selector. Populated lazily on +// first access; cleared in CloseWindow when the session tears down. +std::vector s_presetNames; +int s_cachedSize = -1; + +void EnsureBgfxTexture() { + if (bgfx::isValid(s_bgfxTex)) return; + s_bgfxTex = bgfx::createTexture2D( + (uint16_t)kFboW, (uint16_t)kFboH, false, 1, + bgfx::TextureFormat::RGBA8, + BGFX_TEXTURE_NONE | BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); +} + +void DestroyBgfxTexture() { + if (bgfx::isValid(s_bgfxTex)) { + bgfx::destroy(s_bgfxTex); + s_bgfxTex = BGFX_INVALID_HANDLE; + } + if (s_readback) { std::free(s_readback); s_readback = nullptr; } +} + +bool OpenWindow() +{ + // projectM 4.x renderer requires GL 3.3 core profile. + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); + + const int kW = 1280, kH = 720; + // Create visible so Metal/SDL allocates the framebuffer, then hide + // immediately. Show on demand via MilkdropWindow_SetPreviewVisible. + s_win = SDL_CreateWindow( + "UIX Desktop / projectM", + SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, + kW, kH, + SDL_WINDOW_OPENGL); + if (!s_win) { + fprintf(stderr, "[Milkdrop] SDL_CreateWindow failed: %s\n", SDL_GetError()); + return false; + } + s_glc = SDL_GL_CreateContext(s_win); + if (!s_glc) { + fprintf(stderr, "[Milkdrop] SDL_GL_CreateContext failed: %s\n", SDL_GetError()); + SDL_DestroyWindow(s_win); s_win = nullptr; + return false; + } + SDL_GL_MakeCurrent(s_win, s_glc); + + if (!LoadGLEntries()) { + fprintf(stderr, "[Milkdrop] could not resolve GL FBO entry points\n"); + SDL_GL_DeleteContext(s_glc); s_glc = nullptr; + SDL_DestroyWindow(s_win); s_win = nullptr; + return false; + } + if (!CreateOffscreenTarget()) { + SDL_GL_DeleteContext(s_glc); s_glc = nullptr; + SDL_DestroyWindow(s_win); s_win = nullptr; + return false; + } + s_readback = (uint8_t*)std::malloc((size_t)kFboW * kFboH * 4); + + s_pM = projectm_create(); + if (!s_pM) { + fprintf(stderr, "[Milkdrop] projectm_create() failed\n"); + DestroyOffscreenTarget(); + SDL_GL_DeleteContext(s_glc); s_glc = nullptr; + SDL_DestroyWindow(s_win); s_win = nullptr; + return false; + } + // projectM's "window size" is really its render target size; match the + // FBO so the projection matrices it computes line up. + projectm_set_window_size(s_pM, (size_t)kFboW, (size_t)kFboH); + projectm_set_mesh_size(s_pM, 32, 24); + projectm_set_fps(s_pM, 60); + projectm_set_beat_sensitivity(s_pM, 1.0f); + projectm_set_aspect_correction(s_pM, true); + projectm_set_preset_duration(s_pM, 22.0); + projectm_set_soft_cut_duration(s_pM, 5.0); + + // Pick a preset directory that actually exists. Probe a known .milk + // inside each (fopen on the bare dir works on macOS but not on Linux). + const char* presetDirs[] = { + "/opt/homebrew/share/projectM/presets/presets_milkdrop_200", + "/opt/homebrew/share/projectM/presets/presets_milkdrop_104", + "/usr/local/share/projectM/presets/presets_milkdrop_200", + "/usr/share/projectM/presets/presets_milkdrop_200", + nullptr, + }; + const char* presetPath = nullptr; + for (int i = 0; presetDirs[i]; ++i) { + char probe[512]; + snprintf(probe, sizeof(probe), "%s/.", presetDirs[i]); + FILE* fp = fopen(probe, "r"); + if (fp) { fclose(fp); presetPath = presetDirs[i]; break; } + } + + s_playlist = projectm_playlist_create(s_pM); + if (s_playlist && presetPath) { + uint32_t added = projectm_playlist_add_path(s_playlist, presetPath, true, false); + fprintf(stdout, "[Milkdrop] %u presets loaded from %s\n", added, presetPath); + if (added > 0) projectm_playlist_play_next(s_playlist, true); + } else if (!presetPath) { + fprintf(stderr, "[Milkdrop] no preset directory found; renderer will idle\n"); + } + + SDL_GL_MakeCurrent(s_win, nullptr); + // Hide immediately; user shows it explicitly via the Configure panel. + SDL_HideWindow(s_win); + return true; +} + +void CloseWindow() +{ + DestroyBgfxTexture(); + if (s_playlist) { projectm_playlist_destroy(s_playlist); s_playlist = nullptr; } + if (s_pM) { projectm_destroy(s_pM); s_pM = nullptr; } + DestroyOffscreenTarget(); + if (s_glc) { SDL_GL_DeleteContext(s_glc); s_glc = nullptr; } + if (s_win) { SDL_DestroyWindow(s_win); s_win = nullptr; } + s_presetNames.clear(); + s_cachedSize = -1; +} + +} // anon + +bool MilkdropWindow_IsOpen() { return s_open; } + +void MilkdropWindow_Toggle() +{ + if (s_open) { + CloseWindow(); + s_open = false; + return; + } + if (OpenWindow()) { + s_open = true; + } +} + +void MilkdropWindow_Tick() +{ + if (!s_open || !s_pM || !s_win || !s_glc) return; + + // Pull latest 512 samples from the post-mix ring. projectm_pcm_add_int16 + // wants interleaved stereo shorts, count in frames, channel count = 2. + int16_t left[512], right[512]; + DashAudio_GetPCMSamples(left, right, 512); + + + int16_t interleaved[1024]; + for (int i = 0; i < 512; ++i) { + interleaved[i * 2 + 0] = left[i]; + interleaved[i * 2 + 1] = right[i]; + } + projectm_pcm_add_int16(s_pM, interleaved, 512, PROJECTM_STEREO); + + SDL_GL_MakeCurrent(s_win, s_glc); + + // Render projectM into our offscreen FBO at its native size, then blit + // to the visible window's framebuffer. Phase A keeps the window so we + // can confirm visually; Phase B will read pixels back instead and the + // window can be hidden. + projectm_opengl_render_frame_fbo(s_pM, s_fbo); + + // Read pixels back to CPU, push into the bgfx texture for the scene. + if (s_readback) { + s_glBindFramebuffer(GL_READ_FRAMEBUFFER, s_fbo); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, kFboW, kFboH, GL_RGBA, GL_UNSIGNED_BYTE, s_readback); + s_glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + + EnsureBgfxTexture(); + if (bgfx::isValid(s_bgfxTex)) { + const uint32_t bytes = (uint32_t)kFboW * kFboH * 4; + bgfx::updateTexture2D(s_bgfxTex, 0, 0, 0, 0, + (uint16_t)kFboW, (uint16_t)kFboH, + bgfx::copy(s_readback, bytes)); + } + } + + int winW = 0, winH = 0; + SDL_GetWindowSize(s_win, &winW, &winH); + if (winW > 0 && winH > 0) { + s_glBindFramebuffer(GL_READ_FRAMEBUFFER, s_fbo); + s_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + s_glBlitFramebuffer(0, 0, kFboW, kFboH, + 0, 0, winW, winH, + GL_COLOR_BUFFER_BIT, GL_LINEAR); + s_glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + SDL_GL_SwapWindow(s_win); + SDL_GL_MakeCurrent(s_win, nullptr); + + // Handle close requests on the secondary window so the user can hit + // the red x as well as toggling X+Y again. + Uint32 wid = SDL_GetWindowID(s_win); + SDL_Event ev; + while (SDL_PeepEvents(&ev, 1, SDL_GETEVENT, SDL_WINDOWEVENT, SDL_WINDOWEVENT) > 0) { + if (ev.window.windowID == wid && ev.window.event == SDL_WINDOWEVENT_CLOSE) { + CloseWindow(); + s_open = false; + return; + } + } +} + +void MilkdropWindow_Shutdown() +{ + if (s_open) { + CloseWindow(); + s_open = false; + } +} + +unsigned int MilkdropWindow_GetWindowID() +{ + return s_win ? (unsigned int)SDL_GetWindowID(s_win) : 0; +} + +void MilkdropWindow_ToggleFullscreen() +{ + if (!s_win) return; + Uint32 flags = SDL_GetWindowFlags(s_win); + bool fs = (flags & SDL_WINDOW_FULLSCREEN_DESKTOP) != 0; + SDL_SetWindowFullscreen(s_win, fs ? 0 : SDL_WINDOW_FULLSCREEN_DESKTOP); +} + +unsigned short MilkdropWindow_GetBgfxTexId() +{ + return bgfx::isValid(s_bgfxTex) ? s_bgfxTex.idx : (unsigned short)0xFFFF; +} + +int MilkdropWindow_GetTexW() { return bgfx::isValid(s_bgfxTex) ? kFboW : 0; } +int MilkdropWindow_GetTexH() { return bgfx::isValid(s_bgfxTex) ? kFboH : 0; } + +const unsigned char* MilkdropWindow_GetReadbackRGBA(int* outW, int* outH) +{ + if (!s_open || !s_readback) return nullptr; + if (outW) *outW = kFboW; + if (outH) *outH = kFboH; + return s_readback; +} + +void MilkdropWindow_SetPreviewVisible(bool show) +{ + if (!s_win) return; + if (show) SDL_ShowWindow(s_win); + else SDL_HideWindow(s_win); +} + +// Debounce: ignore rapid repeats within this window so accidental +// double-clicks / sticky UI events don't skip presets in bursts. +static Uint32 s_lastPresetActionMs = 0; +static bool PresetActionDebounce() { + Uint32 now = SDL_GetTicks(); + if (now - s_lastPresetActionMs < 250) return false; + s_lastPresetActionMs = now; + return true; +} + +// Switching presets compiles shader programs, which requires projectM's GL +// context to be current. Outside of Tick, the dashboard's bgfx context owns +// GL, so any explicit nav call must rebind first or the new preset's GLSL +// compile silently fails and the preview goes black. +static void JumpToPreset(uint32_t idx) { + if (!s_playlist || !s_win || !s_glc) return; + SDL_GL_MakeCurrent(s_win, s_glc); + bool wasLocked = s_pM && projectm_get_preset_locked(s_pM); + if (wasLocked) projectm_set_preset_locked(s_pM, false); + projectm_playlist_set_position(s_playlist, idx, false); + if (wasLocked) projectm_set_preset_locked(s_pM, true); + SDL_GL_MakeCurrent(s_win, nullptr); +} + +void MilkdropWindow_NextPreset() +{ + if (!s_playlist || !PresetActionDebounce()) return; + uint32_t sz = projectm_playlist_size(s_playlist); + if (sz == 0) return; + uint32_t cur = projectm_playlist_get_position(s_playlist); + JumpToPreset((cur + 1) % sz); +} + +void MilkdropWindow_PreviousPreset() +{ + if (!s_playlist || !PresetActionDebounce()) return; + uint32_t sz = projectm_playlist_size(s_playlist); + if (sz == 0) return; + uint32_t cur = projectm_playlist_get_position(s_playlist); + JumpToPreset((cur == 0) ? (sz - 1) : (cur - 1)); +} + +float MilkdropWindow_GetBeatSensitivity() +{ + return s_pM ? projectm_get_beat_sensitivity(s_pM) : 1.0f; +} + +void MilkdropWindow_SetBeatSensitivity(float v) +{ + if (s_pM) projectm_set_beat_sensitivity(s_pM, v); +} + +double MilkdropWindow_GetPresetDuration() +{ + return s_pM ? projectm_get_preset_duration(s_pM) : 22.0; +} + +void MilkdropWindow_SetPresetDuration(double seconds) +{ + if (s_pM) projectm_set_preset_duration(s_pM, seconds); +} + +bool MilkdropWindow_GetPresetLocked() +{ + return s_pM ? projectm_get_preset_locked(s_pM) : false; +} + +void MilkdropWindow_SetPresetLocked(bool locked) +{ + if (s_pM) projectm_set_preset_locked(s_pM, locked); +} + +static void EnsurePresetNamesCached() +{ + if (!s_playlist) { s_presetNames.clear(); s_cachedSize = -1; return; } + int sz = (int)projectm_playlist_size(s_playlist); + if (sz == s_cachedSize) return; + s_presetNames.clear(); + s_presetNames.reserve(sz); + for (int i = 0; i < sz; i++) { + char* full = projectm_playlist_item(s_playlist, (uint32_t)i); + if (!full) { s_presetNames.push_back(std::string("(?)")); continue; } + const char* base = full; + for (const char* p = full; *p; p++) { + if (*p == '/' || *p == '\\') base = p + 1; + } + std::string name = base; + size_t dot = name.rfind('.'); + if (dot != std::string::npos) name.resize(dot); + s_presetNames.push_back(std::move(name)); + projectm_playlist_free_string(full); + } + s_cachedSize = sz; +} + +int MilkdropWindow_GetPresetCount() +{ + return s_playlist ? (int)projectm_playlist_size(s_playlist) : 0; +} + +const char* MilkdropWindow_GetPresetName(int idx) +{ + EnsurePresetNamesCached(); + if (idx < 0 || idx >= (int)s_presetNames.size()) return ""; + return s_presetNames[idx].c_str(); +} + +int MilkdropWindow_GetCurrentPresetIndex() +{ + return s_playlist ? (int)projectm_playlist_get_position(s_playlist) : 0; +} + +void MilkdropWindow_SetPresetIndex(int idx) +{ + if (!s_playlist) return; + uint32_t sz = projectm_playlist_size(s_playlist); + if (idx < 0 || (uint32_t)idx >= sz) return; + JumpToPreset((uint32_t)idx); +} + +#endif // THESEUS_HAS_MILKDROP diff --git a/theseus/desktop/milkdrop_window.h b/theseus/desktop/milkdrop_window.h new file mode 100644 index 0000000..92fe1a4 --- /dev/null +++ b/theseus/desktop/milkdrop_window.h @@ -0,0 +1,59 @@ +// milkdrop_window.h +// +// Phase 1 of the milkdrop integration. Owns a second SDL window + its own +// GL context + a libprojectM instance. The PCM ring buffer that the +// dashboard already feeds (Mix_SetPostMix -> DashAudio_GetPCMSamples) is +// what we hand to projectM each frame. Audio output is unchanged - same +// SDL_mixer stream to the same speakers, we just visualize alongside. +// +// When libprojectM is not present at build time (no pkg-config hit), +// THESEUS_HAS_MILKDROP is undefined and these all compile to no-ops so +// the main loop can call them unconditionally. + +#pragma once + +bool MilkdropWindow_IsOpen(); +void MilkdropWindow_Toggle(); +void MilkdropWindow_Tick(); +void MilkdropWindow_Shutdown(); + +// SDL window ID of the projectM window (0 if not open). Used by the main +// event loop to route F11 to ToggleFullscreen below when the projectM +// window owns the keystroke. +unsigned int MilkdropWindow_GetWindowID(); +void MilkdropWindow_ToggleFullscreen(); + +// bgfx texture handle ID for the in-scene projectM texture; 0xFFFF when +// invalid (window closed, bgfx not ready, or build has no projectM). +// The scene side casts back to bgfx::TextureHandle{idx}. +unsigned short MilkdropWindow_GetBgfxTexId(); +int MilkdropWindow_GetTexW(); +int MilkdropWindow_GetTexH(); + +// Raw RGBA readback for code paths that already have a CPU buffer to fill +// (CDynamicTexture's LockRect). Returns NULL when no live render is up. +// outW / outH receive the source dimensions; can be NULL. +const unsigned char* MilkdropWindow_GetReadbackRGBA(int* outW, int* outH); + +// Preview window visibility (the secondary SDL window). Hidden by default +// because the fullscreen overlay does the user-facing rendering; show it +// when the configure UI is up so the user can see the preset they're +// adjusting. +void MilkdropWindow_SetPreviewVisible(bool show); + +// projectM live controls. No-ops when the session isn't running. +void MilkdropWindow_NextPreset(); +void MilkdropWindow_PreviousPreset(); +float MilkdropWindow_GetBeatSensitivity(); +void MilkdropWindow_SetBeatSensitivity(float v); +double MilkdropWindow_GetPresetDuration(); +void MilkdropWindow_SetPresetDuration(double seconds); +bool MilkdropWindow_GetPresetLocked(); +void MilkdropWindow_SetPresetLocked(bool locked); + +// Preset list access for a dropdown selector. Names returned are basenames +// with the .milk extension stripped. Cached lazily inside. +int MilkdropWindow_GetPresetCount(); +const char* MilkdropWindow_GetPresetName(int idx); +int MilkdropWindow_GetCurrentPresetIndex(); +void MilkdropWindow_SetPresetIndex(int idx); diff --git a/theseus/desktop/plex_client.cpp b/theseus/desktop/plex_client.cpp new file mode 100644 index 0000000..d2f3aff --- /dev/null +++ b/theseus/desktop/plex_client.cpp @@ -0,0 +1,926 @@ +// plex_client.cpp: Plex Media Server HTTPS client. + +#include "plex_client.h" +#include "http_util.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern char g_plexToken[256]; +extern char g_plexClientId[64]; +extern void SaveDesktopSettings(); + + +// ============================================================================ +// Plex client identity +// ============================================================================ + +// RFC 4122 v4 UUID. Persisted to desktop.ini so it's stable per install. +static void GenerateUUID(char out[37]) +{ + unsigned char b[16]; + for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xFF); + b[6] = (b[6] & 0x0F) | 0x40; // version 4 + b[8] = (b[8] & 0x3F) | 0x80; // variant 1 + snprintf(out, 37, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], + b[8],b[9], b[10],b[11],b[12],b[13],b[14],b[15]); +} + +static HttpHeaders PlexHeaders(bool wantJson, bool withToken) +{ + HttpHeaders h; + if (wantJson) h.push_back({ "Accept", "application/json" }); + h.push_back({ "X-Plex-Client-Identifier", g_plexClientId }); + h.push_back({ "X-Plex-Product", "UIX Desktop" }); + h.push_back({ "X-Plex-Version", "0.3" }); + h.push_back({ "X-Plex-Device-Name", "UIX Desktop" }); +#if defined(__APPLE__) + h.push_back({ "X-Plex-Platform", "macOS" }); +#elif defined(_WIN32) + h.push_back({ "X-Plex-Platform", "Windows" }); +#else + h.push_back({ "X-Plex-Platform", "Linux" }); +#endif + if (withToken && g_plexToken[0]) { + h.push_back({ "X-Plex-Token", g_plexToken }); + } + return h; +} + + +// ============================================================================ +// Targeted JSON field extraction (same shape as tmdb.cpp). +// ============================================================================ + +static size_t Json_FindKey(const std::string& s, const char* key, size_t from = 0) +{ + std::string needle = "\""; + needle += key; + needle += "\""; + size_t k = s.find(needle, from); + if (k == std::string::npos) return std::string::npos; + size_t c = s.find(':', k + needle.size()); + if (c == std::string::npos) return std::string::npos; + c++; + while (c < s.size() && (s[c] == ' ' || s[c] == '\t')) c++; + return c; +} + +static std::string Json_DecodeString(const char* s, size_t len) +{ + std::string out; + out.reserve(len); + for (size_t i = 0; i < len; i++) { + char c = s[i]; + if (c != '\\' || i + 1 >= len) { out.push_back(c); continue; } + char n = s[++i]; + switch (n) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + default: out.push_back(n); break; + } + } + return out; +} + +static std::string Json_GetString(const std::string& s, const char* key, size_t from = 0) +{ + size_t v = Json_FindKey(s, key, from); + if (v == std::string::npos || v >= s.size() || s[v] != '"') return ""; + v++; + size_t end = v; + while (end < s.size()) { + if (s[end] == '\\' && end + 1 < s.size()) { end += 2; continue; } + if (s[end] == '"') break; + end++; + } + if (end >= s.size()) return ""; + return Json_DecodeString(s.data() + v, end - v); +} + +static int Json_GetInt(const std::string& s, const char* key, size_t from = 0) +{ + size_t v = Json_FindKey(s, key, from); + if (v == std::string::npos) return 0; + return (int)strtol(s.c_str() + v, NULL, 10); +} + +// Split a JSON array into top-level object substrings. Stops at the matching +// ']'. Returns vector of substrings (each starts with '{', ends with '}'). +static std::vector Json_SplitArray(const std::string& s, size_t arrStart) +{ + std::vector out; + if (arrStart >= s.size() || s[arrStart] != '[') return out; + size_t i = arrStart + 1; + while (i < s.size()) { + while (i < s.size() && (s[i] == ' ' || s[i] == '\n' || s[i] == '\t' || s[i] == ',')) i++; + if (i >= s.size() || s[i] == ']') break; + if (s[i] != '{') { i++; continue; } + size_t start = i; + int depth = 0; + bool inStr = false; + for (; i < s.size(); i++) { + char c = s[i]; + if (inStr) { + if (c == '\\' && i + 1 < s.size()) { i++; continue; } + if (c == '"') inStr = false; + } else { + if (c == '"') inStr = true; + else if (c == '{') depth++; + else if (c == '}') { + depth--; + if (depth == 0) { i++; break; } + } + } + } + out.push_back(s.substr(start, i - start)); + } + return out; +} + +// Find the named array's '[' offset, or npos. +static size_t Json_FindArray(const std::string& s, const char* key) +{ + size_t v = Json_FindKey(s, key); + if (v == std::string::npos) return std::string::npos; + while (v < s.size() && (s[v] == ' ' || s[v] == '\t')) v++; + if (v >= s.size() || s[v] != '[') return std::string::npos; + return v; +} + + +// ============================================================================ +// In-flight state. One slot per fetch op, guarded by g_mtx + ready atomic. +// ============================================================================ + +static std::mutex g_mtx; + +// PIN auth +static std::atomic s_pinInFlight{false}; +static std::atomic s_pinCancel{false}; +static std::string s_pinCode; +static std::thread s_pinThread; + +// Server discovery +static std::atomic s_serversReady{false}; +static std::vector s_servers; +static std::thread s_serversThread; +static std::string s_activeServerName; + +// Library list (most recently fetched server) +static std::atomic s_libsReady{false}; +static std::vector s_libs; +static std::thread s_libsThread; + +// Library items (most recently fetched section) +static std::atomic s_itemsReady{false}; +static std::vector s_items; +static std::thread s_itemsThread; + +// TV seasons (children of a show) +static std::atomic s_seasonsReady{false}; +static std::vector s_seasons; +static std::thread s_seasonsThread; + +// TV episodes (children of a season) +static std::atomic s_episodesReady{false}; +static std::vector s_episodes; +static std::thread s_episodesThread; + +// Art download queue +static std::mutex s_artMtx; +static std::map*> s_artInFlight; + +// ---------- Pre-stage cache ---------- +static std::atomic s_syncReady{false}; +static std::atomic s_syncRunning{false}; +static std::atomic s_syncProgress{0}; // 0..1000 +static std::string s_syncPhase; +static std::thread s_syncThread; +static std::vector s_cacheLibs; +static std::map> s_cacheItems; // sectionKey -> items +static std::map> s_cacheSeasons; // showRk -> seasons +static std::map> s_cacheEpisodes; // seasonRk -> episodes + + +// ============================================================================ +// Init / shutdown +// ============================================================================ + +void Plex_Init() +{ + static bool done = false; + if (done) return; + done = true; + + srand((unsigned)time(NULL)); + if (g_plexClientId[0] == 0) { + GenerateUUID(g_plexClientId); + SaveDesktopSettings(); + } + fprintf(stderr, "[Plex] init (token=%s, clientId=%s)\n", + g_plexToken[0] ? "yes" : "no", g_plexClientId); +} + +static void JoinIf(std::thread& t) { + if (t.joinable()) t.join(); +} + +void Plex_Shutdown() +{ + s_pinCancel = true; + JoinIf(s_pinThread); + JoinIf(s_serversThread); + JoinIf(s_libsThread); + JoinIf(s_itemsThread); + JoinIf(s_seasonsThread); + JoinIf(s_episodesThread); + JoinIf(s_syncThread); +} + +bool Plex_HasToken() { return g_plexToken[0] != 0; } + +void Plex_SignOut() +{ + g_plexToken[0] = 0; + std::lock_guard lk(g_mtx); + s_servers.clear(); s_serversReady = false; + s_libs.clear(); s_libsReady = false; + s_items.clear(); s_itemsReady = false; + s_seasons.clear(); s_seasonsReady = false; + s_episodes.clear(); s_episodesReady = false; + s_activeServerName.clear(); + s_cacheLibs.clear(); + s_cacheItems.clear(); + s_cacheSeasons.clear(); + s_cacheEpisodes.clear(); + s_syncReady = false; + s_syncRunning = false; + s_syncProgress = 0; + s_syncPhase.clear(); +} + + +// ============================================================================ +// PIN auth flow +// ============================================================================ + +static void PinAuthWorker() +{ + // No strong=true -- strong pins return a 24-char token plex.tv/link rejects. + HttpResponse r = Http_Post("https://plex.tv/api/v2/pins", "", + PlexHeaders(true, false)); + if (!r.ok()) { + fprintf(stderr, "[Plex] pin create failed (status %ld)\n", r.status); + s_pinInFlight = false; + return; + } + int pinId = Json_GetInt(r.body, "id"); + std::string code = Json_GetString(r.body, "code"); + if (pinId == 0 || code.empty()) { + fprintf(stderr, "[Plex] pin create returned bad json: %s\n", r.body.c_str()); + s_pinInFlight = false; + return; + } + + { + std::lock_guard lk(g_mtx); + s_pinCode = code; + } + fprintf(stderr, "[Plex] pin code = %s (id %d). Visit plex.tv/link\n", + code.c_str(), pinId); + + // Plex pins live ~15min; cap polling at 10. + char url[256]; + snprintf(url, sizeof(url), "https://plex.tv/api/v2/pins/%d", pinId); + auto deadline = std::chrono::steady_clock::now() + std::chrono::minutes(10); + while (std::chrono::steady_clock::now() < deadline) { + if (s_pinCancel) break; + std::this_thread::sleep_for(std::chrono::seconds(2)); + if (s_pinCancel) break; + + HttpResponse p = Http_Get(url, PlexHeaders(true, false)); + if (!p.ok()) continue; + std::string tok = Json_GetString(p.body, "authToken"); + if (tok.empty() || tok == "null") continue; + + strncpy(g_plexToken, tok.c_str(), sizeof(g_plexToken) - 1); + g_plexToken[sizeof(g_plexToken) - 1] = 0; + SaveDesktopSettings(); + fprintf(stderr, "[Plex] token acquired\n"); + break; + } + + { + std::lock_guard lk(g_mtx); + s_pinCode.clear(); + } + s_pinInFlight = false; +} + +void Plex_StartPinAuth() +{ + Plex_Init(); + if (s_pinInFlight) return; + JoinIf(s_pinThread); + s_pinCancel = false; + s_pinInFlight = true; + s_pinThread = std::thread(PinAuthWorker); +} + +void Plex_CancelPinAuth() +{ + s_pinCancel = true; +} + +bool Plex_PinAuthInFlight() { return s_pinInFlight; } + +std::string Plex_GetPinCode() +{ + std::lock_guard lk(g_mtx); + return s_pinCode; +} + + +// ============================================================================ +// Server discovery +// ============================================================================ + +// Prefer local URIs, fall back to first https, then first http. +static std::string PickConnection(const std::string& serverJson) +{ + size_t arr = Json_FindArray(serverJson, "connections"); + if (arr == std::string::npos) return ""; + std::vector conns = Json_SplitArray(serverJson, arr); + std::string firstHttps, firstHttp; + for (const auto& c : conns) { + std::string uri = Json_GetString(c, "uri"); + std::string addr = Json_GetString(c, "address"); + // "local": true is the most reliable signal. + size_t lk = Json_FindKey(c, "local"); + bool isLocal = (lk != std::string::npos && + strncmp(c.c_str() + lk, "true", 4) == 0); + if (isLocal && !uri.empty()) return uri; + if (firstHttps.empty() && uri.rfind("https://", 0) == 0) firstHttps = uri; + if (firstHttp.empty() && uri.rfind("http://", 0) == 0) firstHttp = uri; + } + if (!firstHttps.empty()) return firstHttps; + return firstHttp; +} + +static void ServersWorker() +{ + HttpResponse r = Http_Get( + "https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1", + PlexHeaders(true, true)); + std::vector servers; + if (r.ok()) { + // Top level is an array; wrap so the splitter finds the '['. + std::string wrapped = "{\"resources\":" + r.body + "}"; + size_t arr = Json_FindArray(wrapped, "resources"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(wrapped, arr)) { + std::string product = Json_GetString(obj, "product"); + if (product != "Plex Media Server") continue; + PlexServer s; + s.name = Json_GetString(obj, "name"); + s.clientId = Json_GetString(obj, "clientIdentifier"); + s.uri = PickConnection(obj); + if (!s.uri.empty()) servers.push_back(s); + } + } + } else { + fprintf(stderr, "[Plex] resources fetch failed (status %ld)\n", r.status); + } + + { + std::lock_guard lk(g_mtx); + s_servers = std::move(servers); + s_activeServerName = s_servers.empty() ? std::string() : s_servers[0].name; + } + s_serversReady = true; + fprintf(stderr, "[Plex] discovered %zu server(s)\n", s_servers.size()); +} + +std::string Plex_GetActiveServerName() +{ + std::lock_guard lk(g_mtx); + return s_activeServerName; +} + +void Plex_FetchServers() +{ + Plex_Init(); + JoinIf(s_serversThread); + s_serversReady = false; + s_serversThread = std::thread(ServersWorker); +} + +std::vector Plex_GetServers() +{ + std::lock_guard lk(g_mtx); + return s_servers; +} + +bool Plex_ServersReady() { return s_serversReady; } + + +// ============================================================================ +// Library list +// ============================================================================ + +static void LibrariesWorker(std::string serverUri) +{ + std::string url = serverUri + "/library/sections"; + HttpResponse r = Http_Get(url, PlexHeaders(true, true)); + std::vector libs; + if (r.ok()) { + // Plex wraps section list as { MediaContainer: { Directory: [...] } } + size_t arr = Json_FindArray(r.body, "Directory"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(r.body, arr)) { + PlexLibrary lib; + lib.sectionKey = Json_GetString(obj, "key"); + lib.title = Json_GetString(obj, "title"); + lib.type = Json_GetString(obj, "type"); + if (!lib.sectionKey.empty()) libs.push_back(lib); + } + } + } else { + fprintf(stderr, "[Plex] sections fetch failed (status %ld)\n", r.status); + } + + { + std::lock_guard lk(g_mtx); + s_libs = std::move(libs); + } + s_libsReady = true; + fprintf(stderr, "[Plex] %zu librar%s on %s\n", + s_libs.size(), s_libs.size() == 1 ? "y" : "ies", serverUri.c_str()); +} + +void Plex_FetchLibraries(const std::string& serverUri) +{ + Plex_Init(); + JoinIf(s_libsThread); + s_libsReady = false; + s_libsThread = std::thread(LibrariesWorker, serverUri); +} + +std::vector Plex_GetLibraries() +{ + std::lock_guard lk(g_mtx); + return s_libs; +} + +bool Plex_LibrariesReady() { return s_libsReady; } + + +// ============================================================================ +// Library items +// ============================================================================ + +// "{serverUri}{path}?X-Plex-Token={token}" for thumb / art / stream URLs. +static std::string BuildAssetUrl(const std::string& serverUri, + const std::string& path) +{ + if (path.empty()) return ""; + std::string out = serverUri + path; + out += (path.find('?') == std::string::npos) ? "?" : "&"; + out += "X-Plex-Token="; + out += g_plexToken; + return out; +} + +static void ItemsWorker(std::string serverUri, std::string sectionKey) +{ + std::string url = serverUri + "/library/sections/" + sectionKey + "/all"; + HttpResponse r = Http_Get(url, PlexHeaders(true, true)); + std::vector items; + if (r.ok()) { + size_t arr = Json_FindArray(r.body, "Metadata"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(r.body, arr)) { + PlexItem it; + it.ratingKey = Json_GetString(obj, "ratingKey"); + it.title = Json_GetString(obj, "title"); + int y = Json_GetInt(obj, "year"); + if (y > 0) { + char ybuf[8]; snprintf(ybuf, sizeof(ybuf), "%d", y); + it.year = ybuf; + } + it.summary = Json_GetString(obj, "summary"); + it.thumbUrl = BuildAssetUrl(serverUri, Json_GetString(obj, "thumb")); + it.artUrl = BuildAssetUrl(serverUri, Json_GetString(obj, "art")); + it.type = Json_GetString(obj, "type"); + if (!it.ratingKey.empty()) items.push_back(it); + } + } + } else { + fprintf(stderr, "[Plex] items fetch failed (status %ld)\n", r.status); + } + + { + std::lock_guard lk(g_mtx); + s_items = std::move(items); + } + s_itemsReady = true; + fprintf(stderr, "[Plex] %zu item(s) in section %s\n", s_items.size(), sectionKey.c_str()); +} + +void Plex_FetchItems(const std::string& serverUri, const std::string& sectionKey) +{ + Plex_Init(); + JoinIf(s_itemsThread); + s_itemsReady = false; + s_itemsThread = std::thread(ItemsWorker, serverUri, sectionKey); +} + +std::vector Plex_GetItems() +{ + std::lock_guard lk(g_mtx); + return s_items; +} + +bool Plex_ItemsReady() { return s_itemsReady; } + + +// ============================================================================ +// TV drill -- /library/metadata/{rk}/children returns seasons or episodes. +// ============================================================================ + +static void SeasonsWorker(std::string serverUri, std::string showRk) +{ + std::string url = serverUri + "/library/metadata/" + showRk + "/children"; + HttpResponse r = Http_Get(url, PlexHeaders(true, true)); + std::vector seasons; + if (r.ok()) { + size_t arr = Json_FindArray(r.body, "Metadata"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(r.body, arr)) { + // Skip Plex's synthetic "All episodes" pseudo-season (idx 0). + int idx = Json_GetInt(obj, "index"); + if (idx <= 0) continue; + PlexSeason s; + s.ratingKey = Json_GetString(obj, "ratingKey"); + s.title = Json_GetString(obj, "title"); + char ibuf[8]; snprintf(ibuf, sizeof(ibuf), "%d", idx); + s.index = ibuf; + s.thumbUrl = BuildAssetUrl(serverUri, Json_GetString(obj, "thumb")); + if (!s.ratingKey.empty()) seasons.push_back(s); + } + } + } else { + fprintf(stderr, "[Plex] seasons fetch failed (status %ld)\n", r.status); + } + + { + std::lock_guard lk(g_mtx); + s_seasons = seasons; + s_cacheSeasons[showRk] = std::move(seasons); + } + s_seasonsReady = true; + fprintf(stderr, "[Plex] %zu season(s) under show %s\n", + s_seasons.size(), showRk.c_str()); +} + +void Plex_FetchSeasons(const std::string& serverUri, const std::string& showRatingKey) +{ + Plex_Init(); + JoinIf(s_seasonsThread); + s_seasonsReady = false; + s_seasonsThread = std::thread(SeasonsWorker, serverUri, showRatingKey); +} + +std::vector Plex_GetSeasons() +{ + std::lock_guard lk(g_mtx); + return s_seasons; +} + +bool Plex_SeasonsReady() { return s_seasonsReady; } + + +static void EpisodesWorker(std::string serverUri, std::string seasonRk) +{ + std::string url = serverUri + "/library/metadata/" + seasonRk + "/children"; + HttpResponse r = Http_Get(url, PlexHeaders(true, true)); + std::vector episodes; + if (r.ok()) { + size_t arr = Json_FindArray(r.body, "Metadata"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(r.body, arr)) { + PlexEpisode e; + e.ratingKey = Json_GetString(obj, "ratingKey"); + e.title = Json_GetString(obj, "title"); + int idx = Json_GetInt(obj, "index"); + if (idx > 0) { + char ibuf[8]; snprintf(ibuf, sizeof(ibuf), "%d", idx); + e.index = ibuf; + } + e.summary = Json_GetString(obj, "summary"); + e.thumbUrl = BuildAssetUrl(serverUri, Json_GetString(obj, "thumb")); + if (!e.ratingKey.empty()) episodes.push_back(e); + } + } + } else { + fprintf(stderr, "[Plex] episodes fetch failed (status %ld)\n", r.status); + } + + { + std::lock_guard lk(g_mtx); + s_episodes = episodes; + s_cacheEpisodes[seasonRk] = std::move(episodes); + } + s_episodesReady = true; + fprintf(stderr, "[Plex] %zu episode(s) under season %s\n", + s_episodes.size(), seasonRk.c_str()); +} + +void Plex_FetchEpisodes(const std::string& serverUri, const std::string& seasonRatingKey) +{ + Plex_Init(); + JoinIf(s_episodesThread); + s_episodesReady = false; + s_episodesThread = std::thread(EpisodesWorker, serverUri, seasonRatingKey); +} + +std::vector Plex_GetEpisodes() +{ + std::lock_guard lk(g_mtx); + return s_episodes; +} + +bool Plex_EpisodesReady() { return s_episodesReady; } + + +// ============================================================================ +// Stream URL resolution. Direct-play only; Plex transcoder negotiation skipped. +// ============================================================================ + +std::string Plex_ResolveStreamUrl(const std::string& serverUri, + const std::string& ratingKey) +{ + if (serverUri.empty() || ratingKey.empty() || g_plexToken[0] == 0) return ""; + + std::string url = serverUri + "/library/metadata/" + ratingKey; + HttpResponse r = Http_Get(url, PlexHeaders(true, true)); + if (!r.ok()) { + fprintf(stderr, "[Plex] metadata fetch failed for %s (status %ld)\n", + ratingKey.c_str(), r.status); + return ""; + } + + // First Media.Part[0] is the direct-play candidate; no alternate walk. + size_t mArr = Json_FindArray(r.body, "Media"); + if (mArr == std::string::npos) return ""; + auto medias = Json_SplitArray(r.body, mArr); + if (medias.empty()) return ""; + + size_t pArr = Json_FindArray(medias[0], "Part"); + if (pArr == std::string::npos) return ""; + auto parts = Json_SplitArray(medias[0], pArr); + if (parts.empty()) return ""; + + std::string partKey = Json_GetString(parts[0], "key"); + if (partKey.empty()) return ""; + + std::string out = serverUri + partKey; + out += (partKey.find('?') == std::string::npos) ? "?" : "&"; + out += "X-Plex-Token="; + out += g_plexToken; + return out; +} + + +// ============================================================================ +// Pre-stage cache walker. Eager: servers + libraries + items. Seasons + +// episodes stay lazy (the fanout is too wide to walk on every sign-in). +// ============================================================================ + +static void SetSyncPhase(const std::string& phase, int progressOf1000) +{ + std::lock_guard lk(g_mtx); + s_syncPhase = phase; + s_syncProgress = progressOf1000; +} + +static void SyncWorker() +{ + s_syncReady = false; + + SetSyncPhase("Finding Plex server", 0); + Plex_FetchServers(); + while (!Plex_ServersReady()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + std::vector servers = Plex_GetServers(); + if (servers.empty()) { + SetSyncPhase("No Plex servers reachable", 1000); + s_syncReady = true; + s_syncRunning = false; + return; + } + std::string serverUri = servers[0].uri; + + SetSyncPhase("Loading library list", 50); + HttpResponse r = Http_Get(serverUri + "/library/sections", + PlexHeaders(true, true)); + std::vector libs; + if (r.ok()) { + size_t arr = Json_FindArray(r.body, "Directory"); + if (arr != std::string::npos) { + for (const auto& obj : Json_SplitArray(r.body, arr)) { + PlexLibrary lib; + lib.sectionKey = Json_GetString(obj, "key"); + lib.title = Json_GetString(obj, "title"); + lib.type = Json_GetString(obj, "type"); + if (!lib.sectionKey.empty()) libs.push_back(lib); + } + } + } + + std::map> itemsByKey; + const int libCount = (int)libs.size(); + for (int i = 0; i < libCount; i++) { + const PlexLibrary& lib = libs[i]; + char phaseBuf[128]; + snprintf(phaseBuf, sizeof(phaseBuf), + "Loading %s (%d/%d)", lib.title.c_str(), i + 1, libCount); + SetSyncPhase(phaseBuf, 100 + (i * 900) / (libCount > 0 ? libCount : 1)); + + std::string url = serverUri + "/library/sections/" + lib.sectionKey + "/all"; + HttpResponse ir = Http_Get(url, PlexHeaders(true, true)); + std::vector items; + if (ir.ok()) { + size_t mArr = Json_FindArray(ir.body, "Metadata"); + if (mArr != std::string::npos) { + for (const auto& obj : Json_SplitArray(ir.body, mArr)) { + PlexItem it; + it.ratingKey = Json_GetString(obj, "ratingKey"); + it.title = Json_GetString(obj, "title"); + int y = Json_GetInt(obj, "year"); + if (y > 0) { + char ybuf[8]; snprintf(ybuf, sizeof(ybuf), "%d", y); + it.year = ybuf; + } + it.summary = Json_GetString(obj, "summary"); + it.thumbUrl = BuildAssetUrl(serverUri, Json_GetString(obj, "thumb")); + it.artUrl = BuildAssetUrl(serverUri, Json_GetString(obj, "art")); + it.type = Json_GetString(obj, "type"); + if (!it.ratingKey.empty()) items.push_back(it); + } + } + } + itemsByKey[lib.sectionKey] = std::move(items); + } + + // Publish atomically -- readers see old snapshot or whole new one. + { + std::lock_guard lk(g_mtx); + s_cacheLibs = std::move(libs); + s_cacheItems = std::move(itemsByKey); + } + + SetSyncPhase("Plex library ready", 1000); + s_syncReady = true; + s_syncRunning = false; + fprintf(stderr, "[Plex] sync complete (%zu libraries)\n", s_cacheLibs.size()); +} + +void Plex_StartSync() +{ + Plex_Init(); + if (!Plex_HasToken()) return; + if (s_syncRunning) return; + if (s_syncReady) return; + JoinIf(s_syncThread); + s_syncRunning = true; + s_syncThread = std::thread(SyncWorker); +} + +bool Plex_SyncReady() { return s_syncReady; } +int Plex_SyncProgress() { return s_syncProgress; } +std::string Plex_SyncPhase() +{ + std::lock_guard lk(g_mtx); + return s_syncPhase; +} + +std::vector Plex_Cache_GetLibraries() +{ + std::lock_guard lk(g_mtx); + return s_cacheLibs; +} + +std::vector Plex_Cache_GetItems(const std::string& sectionKey) +{ + std::lock_guard lk(g_mtx); + auto it = s_cacheItems.find(sectionKey); + if (it == s_cacheItems.end()) return {}; + return it->second; +} + +bool Plex_Cache_GetSeasons(const std::string& showRatingKey, + std::vector& out) +{ + { + std::lock_guard lk(g_mtx); + auto it = s_cacheSeasons.find(showRatingKey); + if (it != s_cacheSeasons.end()) { + out = it->second; + return true; + } + } + // Not cached -- fire a fetch; SeasonsWorker promotes the result. + if (!s_servers.empty()) { + Plex_FetchSeasons(s_servers.front().uri, showRatingKey); + } + return false; +} + +bool Plex_Cache_GetEpisodes(const std::string& seasonRatingKey, + std::vector& out) +{ + { + std::lock_guard lk(g_mtx); + auto it = s_cacheEpisodes.find(seasonRatingKey); + if (it != s_cacheEpisodes.end()) { + out = it->second; + return true; + } + } + if (!s_servers.empty()) { + Plex_FetchEpisodes(s_servers.front().uri, seasonRatingKey); + } + return false; +} + + +// ============================================================================ +// Art cache +// ============================================================================ + +#include +static inline int Mkdir_(const char* path) { +#ifdef _WIN32 + return mkdir(path); +#else + return mkdir(path, 0755); +#endif +} + +// Host path -- curl writes here. Lives at Library/Plex/.jpg next to binary. +static std::string Plex_ArtCacheHostPath(const std::string& ratingKey) +{ + Mkdir_("Library"); + Mkdir_("Library/Plex"); + return "Library/Plex/" + ratingKey + ".jpg"; +} + +// XBox-style path for the XAP. xboxfs routes E:\ -> Library/. +std::string Plex_ArtCachePath(const std::string& ratingKey) +{ + return "E:\\Plex\\" + ratingKey + ".jpg"; +} + +static void ArtWorker(std::string ratingKey, std::string url, + std::atomic* flag) +{ + std::string path = Plex_ArtCacheHostPath(ratingKey); + struct stat st; + if (stat(path.c_str(), &st) != 0 || st.st_size == 0) { + if (!Http_GetToFile(url, path)) { + fprintf(stderr, "[Plex] art fetch failed for %s\n", ratingKey.c_str()); + } + } + { + std::lock_guard lk(s_artMtx); + s_artInFlight.erase(ratingKey); + } + delete flag; +} + +void Plex_QueueArtDownload(const std::string& ratingKey, const std::string& url) +{ + if (ratingKey.empty() || url.empty()) return; + { + std::lock_guard lk(s_artMtx); + if (s_artInFlight.count(ratingKey)) return; + s_artInFlight[ratingKey] = new std::atomic(false); + } + std::thread(ArtWorker, ratingKey, url, s_artInFlight[ratingKey]).detach(); +} diff --git a/theseus/desktop/plex_client.h b/theseus/desktop/plex_client.h new file mode 100644 index 0000000..69f908d --- /dev/null +++ b/theseus/desktop/plex_client.h @@ -0,0 +1,105 @@ +// plex_client.h: Plex Media Server client. PIN auth + cache-backed browse. + +#pragma once + +#include +#include + +struct PlexServer { + std::string name; // "Milenko's Plex" + std::string clientId; // server-side machineIdentifier + std::string uri; // best connection URI (prefer local) +}; + +struct PlexLibrary { + std::string sectionKey; // numeric id as string + std::string title; // "Movies", "TV", "Music" + std::string type; // "movie" | "show" | "artist" | "photo" +}; + +struct PlexItem { + std::string ratingKey; // numeric id, identifies the item + std::string title; + std::string year; // optional, as string + std::string thumbUrl; // absolute URL (with X-Plex-Token appended) + std::string artUrl; // absolute URL (hero/fanart) + std::string summary; // plot synopsis + std::string type; // "movie" | "show" | "track" | "artist" | ... +}; + +struct PlexSeason { + std::string ratingKey; + std::string title; // "Season 1" + std::string index; // season number as string + std::string thumbUrl; +}; + +struct PlexEpisode { + std::string ratingKey; + std::string title; + std::string index; // episode number as string + std::string summary; + std::string thumbUrl; +}; + + +// Lifecycle +void Plex_Init(); +void Plex_Shutdown(); +bool Plex_HasToken(); +void Plex_SignOut(); + +// PIN auth (plex.tv/link 4-letter code flow) +void Plex_StartPinAuth(); +void Plex_CancelPinAuth(); +bool Plex_PinAuthInFlight(); +std::string Plex_GetPinCode(); + +// Async fetchers + poll-ready flags +void Plex_FetchServers(); +std::vector Plex_GetServers(); +bool Plex_ServersReady(); +std::string Plex_GetActiveServerName(); + +void Plex_FetchLibraries(const std::string& serverUri); +std::vector Plex_GetLibraries(); +bool Plex_LibrariesReady(); + +void Plex_FetchItems(const std::string& serverUri, + const std::string& sectionKey); +std::vector Plex_GetItems(); +bool Plex_ItemsReady(); + +void Plex_FetchSeasons(const std::string& serverUri, + const std::string& showRatingKey); +std::vector Plex_GetSeasons(); +bool Plex_SeasonsReady(); + +void Plex_FetchEpisodes(const std::string& serverUri, + const std::string& seasonRatingKey); +std::vector Plex_GetEpisodes(); +bool Plex_EpisodesReady(); + +// Sync direct-play URL resolver. Empty on failure. +std::string Plex_ResolveStreamUrl(const std::string& serverUri, + const std::string& ratingKey); + +// Pre-stage cache. Walks servers + libraries + items on sign-in. +// Seasons + episodes stay lazy with permanent in-session caching. +void Plex_StartSync(); +bool Plex_SyncReady(); +std::string Plex_SyncPhase(); +int Plex_SyncProgress(); // 0..1000 per-mille + +std::vector Plex_Cache_GetLibraries(); +std::vector Plex_Cache_GetItems(const std::string& sectionKey); +bool Plex_Cache_GetSeasons(const std::string& showRatingKey, + std::vector& out); +bool Plex_Cache_GetEpisodes(const std::string& seasonRatingKey, + std::vector& out); + +// Cover art. Cache path is Xbox-style (E:\Plex\.jpg); xboxfs routes +// it to Library/Plex/ for the texture loader. +std::string Plex_ArtCachePath(const std::string& ratingKey); +void Plex_QueueArtDownload(const std::string& ratingKey, + const std::string& url); diff --git a/theseus/desktop/sdl_main.cpp b/theseus/desktop/sdl_main.cpp index 4541939..47081c6 100644 --- a/theseus/desktop/sdl_main.cpp +++ b/theseus/desktop/sdl_main.cpp @@ -9,6 +9,8 @@ #include "shape_render.h" #include "asset_loader.h" #include "audio_sdl.h" +#include "joystick.h" +#include "milkdrop_window.h" #include "xiso.h" #include "hdd_browser.h" #include "title_maker.h" @@ -346,13 +348,19 @@ static bool s_softRestartPending = false; // reinit after game exits // Audio mute state (Ctrl+M toggle, auto-muted during game launch) bool g_audioMuted = false; // user choice (Ctrl+M) +float g_masterVolume = 1.0f; // 0.0 - 1.0, applied to mixer + libmpv +bool g_useMilkdropViz = false; // opt-in: replace legacy orb viz with projectM bool g_windowFocused = true; // SDL focus state // Mute ambient (SDL_mixer) when: user pressed Ctrl+M, window unfocused, or // media is playing. mpv runs through its own audio out, unaffected by this. +// Skip the focus check while the projectM visualizer is open so it can keep +// reacting to dashboard audio when the user clicks into its window. static void ApplyEffectiveMute() { extern bool g_mediaFullscreen; - bool shouldMute = g_audioMuted || !g_windowFocused || g_mediaFullscreen; + extern bool MilkdropWindow_IsOpen(); + bool focusLost = !g_windowFocused && !MilkdropWindow_IsOpen(); + bool shouldMute = g_audioMuted || focusLost || g_mediaFullscreen; if (shouldMute) DashAudio_MuteAll(); else DashAudio_UnmuteAll(); } @@ -383,6 +391,13 @@ char g_moviesRoot[512] = ""; char g_tvRoot[512] = ""; char g_tmdbKey[128] = ""; // TMDB v3 API key, optional char g_romsDir[512] = ""; // Default ROMs/ISOs root, expanded as $ROMS_DIR in launch templates +char g_plexToken[256] = ""; // PIN-flow token +char g_plexClientId[64] = ""; // UUID, stable per install +char g_jellyfinUrl[512] = ""; +char g_jellyfinToken[256] = ""; +char g_jellyfinUserId[64] = ""; +char g_jellyfinClientId[64] = ""; +char g_jellyfinUserName[128] = ""; int g_startupMode = 0; // 0 = ask, 1 = dashboard, 2 = development bool g_bUseOnScreenKeyboard = false; // when true, ignore physical keyboard during keyboard popups bool g_bShowBootAnimation = true; // play xbox_boot.mp4 once at startup before the dashboard @@ -464,6 +479,34 @@ void LoadDesktopSettings() { strncpy(g_tvRoot, line + 7, sizeof(g_tvRoot) - 1); else if (strncmp(line, "TMDBKey=", 8) == 0) strncpy(g_tmdbKey, line + 8, sizeof(g_tmdbKey) - 1); + else if (strncmp(line, "PlexToken=", 10) == 0) { + strncpy(g_plexToken, line + 10, sizeof(g_plexToken) - 1); + g_plexToken[sizeof(g_plexToken) - 1] = 0; + } + else if (strncmp(line, "PlexClientId=", 13) == 0) { + strncpy(g_plexClientId, line + 13, sizeof(g_plexClientId) - 1); + g_plexClientId[sizeof(g_plexClientId) - 1] = 0; + } + else if (strncmp(line, "JellyfinUrl=", 12) == 0) { + strncpy(g_jellyfinUrl, line + 12, sizeof(g_jellyfinUrl) - 1); + g_jellyfinUrl[sizeof(g_jellyfinUrl) - 1] = 0; + } + else if (strncmp(line, "JellyfinToken=", 14) == 0) { + strncpy(g_jellyfinToken, line + 14, sizeof(g_jellyfinToken) - 1); + g_jellyfinToken[sizeof(g_jellyfinToken) - 1] = 0; + } + else if (strncmp(line, "JellyfinUserId=", 15) == 0) { + strncpy(g_jellyfinUserId, line + 15, sizeof(g_jellyfinUserId) - 1); + g_jellyfinUserId[sizeof(g_jellyfinUserId) - 1] = 0; + } + else if (strncmp(line, "JellyfinClientId=", 17) == 0) { + strncpy(g_jellyfinClientId, line + 17, sizeof(g_jellyfinClientId) - 1); + g_jellyfinClientId[sizeof(g_jellyfinClientId) - 1] = 0; + } + else if (strncmp(line, "JellyfinUserName=", 17) == 0) { + strncpy(g_jellyfinUserName, line + 17, sizeof(g_jellyfinUserName) - 1); + g_jellyfinUserName[sizeof(g_jellyfinUserName) - 1] = 0; + } else if (strncmp(line, "RomsDir=", 8) == 0) strncpy(g_romsDir, line + 8, sizeof(g_romsDir) - 1); else if (strncmp(line, "MSAA=", 5) == 0) { @@ -481,6 +524,13 @@ void LoadDesktopSettings() { } else if (strncmp(line, "Hwdec=", 6) == 0) g_hwdec = atoi(line + 6) != 0; + else if (strncmp(line, "MasterVolume=", 13) == 0) { + float v = (float)atof(line + 13); + if (v < 0.0f) v = 0.0f; if (v > 1.0f) v = 1.0f; + g_masterVolume = v; + } + else if (strncmp(line, "UseMilkdropViz=", 15) == 0) + g_useMilkdropViz = atoi(line + 15) != 0; else if (strncmp(line, "Renderer=", 9) == 0) { const char* v = line + 9; // Order matters: opengles before opengl, metal before "" prefix. @@ -520,9 +570,6 @@ static void RereadLegacyFromDisk() { } else if (strncmp(line, "Use Progressive=", 16) == 0) { strncpy(g_useProgressive, line + 16, sizeof(g_useProgressive) - 1); g_useProgressive[sizeof(g_useProgressive) - 1] = 0; - } else if (strncmp(line, "Current Skin=", 13) == 0) { - strncpy(g_currentSkin, line + 13, sizeof(g_currentSkin) - 1); - g_currentSkin[sizeof(g_currentSkin) - 1] = 0; } } fclose(fp); @@ -551,6 +598,8 @@ void SaveDesktopSettings() { fprintf(fp, "Vsync=%d\n", g_vsyncMode); fprintf(fp, "FpsCap=%d\n", g_fpsCap); fprintf(fp, "Hwdec=%d\n", g_hwdec ? 1 : 0); + fprintf(fp, "MasterVolume=%.3f\n", g_masterVolume); + fprintf(fp, "UseMilkdropViz=%d\n", g_useMilkdropViz ? 1 : 0); { const char* rname = (g_rendererPref == 1) ? "d3d11" : (g_rendererPref == 2) ? "vulkan" @@ -578,6 +627,13 @@ void SaveDesktopSettings() { fprintf(fp, "TvRoot=%s\n", g_tvRoot); fprintf(fp, "TMDBKey=%s\n", g_tmdbKey); fprintf(fp, "RomsDir=%s\n", g_romsDir); + fprintf(fp, "PlexToken=%s\n", g_plexToken); + fprintf(fp, "PlexClientId=%s\n", g_plexClientId); + fprintf(fp, "JellyfinUrl=%s\n", g_jellyfinUrl); + fprintf(fp, "JellyfinToken=%s\n", g_jellyfinToken); + fprintf(fp, "JellyfinUserId=%s\n", g_jellyfinUserId); + fprintf(fp, "JellyfinClientId=%s\n", g_jellyfinClientId); + fprintf(fp, "JellyfinUserName=%s\n", g_jellyfinUserName); // Legacy Q:\System\config.ini sections (aliased to this file by xboxfs.h). fprintf(fp, "\n[Progressive]\n"); fprintf(fp, "Use 720p=%s\n", g_use720p); @@ -750,6 +806,44 @@ static void PreSwapOverlays() { // XAP Editor (F2); floating ImGui window RenderXAPEditor(); + // Skin Editor (Tools menu); floating ImGui window + extern void RenderSkinEditor(); + RenderSkinEditor(); + + // projectM configuration panel (Settings -> Audio -> Configure projectM...) + extern void RenderProjectMConfig(); + RenderProjectMConfig(); + + // projectM fullscreen hijack. Toggle gates whether projectM runs at + // all; X+Y starts the session. Overlay fades in over half a second + // so it doesn't snap on top of the dashboard. Suppressed while + // Settings / Configure projectM are open so the user can see the + // panels they're adjusting. + extern bool g_settingsOpen; + extern bool g_projectMConfigOpen; + bool suppressOverlay = g_settingsOpen || g_projectMConfigOpen; + if (g_useMilkdropViz && !suppressOverlay) { + extern unsigned short MilkdropWindow_GetBgfxTexId(); + unsigned short pmTex = MilkdropWindow_GetBgfxTexId(); + static Uint32 s_fadeStart = 0; + if (pmTex == 0xFFFF) { + s_fadeStart = 0; // session not running — reset for next start + } else { + if (s_fadeStart == 0) s_fadeStart = SDL_GetTicks(); + const float kFadeMs = 500.0f; + float t = (float)(SDL_GetTicks() - s_fadeStart) / kFadeMs; + if (t > 1.0f) t = 1.0f; + int alpha = (int)(t * 235.0f); + int winW = 0, winH = 0; + SDL_GetWindowSize(g_pSDLWindow, &winW, &winH); + ImDrawList* dl = ImGui::GetForegroundDrawList(); + ImU32 tint = IM_COL32(255, 255, 255, alpha); + dl->AddImage((ImTextureID)(intptr_t)pmTex, + ImVec2(0, 0), ImVec2((float)winW, (float)winH), + ImVec2(0, 0), ImVec2(1, 1), tint); + } + } + // Launch overlay (fade-to-black + Xbox logo). Drawn last so it sits // above every other overlay. LaunchOverlay_Tick(); @@ -1096,6 +1190,10 @@ int main(int argc, char* argv[]) { extern void Playlist_LoadAll(); Playlist_LoadAll(); MediaDB_LoadCache(); + extern void Plex_StartSync(); + Plex_StartSync(); + extern void Jellyfin_StartSync(); + Jellyfin_StartSync(); // GL build wraps an OpenGL drawable into the window. BGFX leaves it // plain so the backend layer (CAMetalLayer / Vulkan) can attach. @@ -1747,13 +1845,24 @@ int main(int argc, char* argv[]) { if (event.key.keysym.sym == SDLK_F10) { g_showMenuBar = !g_showMenuBar; } - if (event.key.keysym.sym == SDLK_F11) { - // F11: toggle between windowed and exclusive fullscreen - // through the unified display state so the value persists - // and the in-dashboard Settings reflects it. - g_windowMode = (g_windowMode == 2) ? 0 : 2; - g_displayChangeRequested = true; - SaveDesktopSettings(); + { + extern unsigned int MilkdropWindow_GetWindowID(); + extern void MilkdropWindow_ToggleFullscreen(); + unsigned int mdId = MilkdropWindow_GetWindowID(); + bool onProjectM = (mdId != 0 && event.key.windowID == mdId); + + if (onProjectM && + (event.key.keysym.sym == SDLK_f || + event.key.keysym.sym == SDLK_F11)) { + MilkdropWindow_ToggleFullscreen(); + } + else if (!onProjectM && event.key.keysym.sym == SDLK_F11) { + // F11 on the main window: persist via the unified + // display state so Settings reflects it. + g_windowMode = (g_windowMode == 2) ? 0 : 2; + g_displayChangeRequested = true; + SaveDesktopSettings(); + } } if (event.key.keysym.sym == SDLK_F12) { // F12: toggle borderless windowed (no title bar / @@ -1916,6 +2025,32 @@ int main(int argc, char* argv[]) { fpsCapPrev = SDL_GetPerformanceCounter(); } + // X+Y combo (keyboard or controller) toggles the MilkDrop window. + // Original Xbox music visualizer used the same combo to fullscreen + // the orb viz; we repurpose it to spawn / dismiss the libprojectM + // overlay window. + { + const Uint8* keys = SDL_GetKeyboardState(NULL); + bool kbXY = keys[SDL_SCANCODE_X] && keys[SDL_SCANCODE_Y]; + bool padXY = false; + if (CJoystick::c_controller) { + padXY = SDL_GameControllerGetButton(CJoystick::c_controller, SDL_CONTROLLER_BUTTON_X) + && SDL_GameControllerGetButton(CJoystick::c_controller, SDL_CONTROLLER_BUTTON_Y); + } + static bool s_prevXY = false; + bool xy = kbXY || padXY; + if (xy && !s_prevXY && g_useMilkdropViz) + MilkdropWindow_Toggle(); + s_prevXY = xy; + } + // Force projectM shutdown if the user just unchecked the toggle. + static bool s_prevToggle = false; + if (s_prevToggle && !g_useMilkdropViz && MilkdropWindow_IsOpen()) + MilkdropWindow_Shutdown(); + s_prevToggle = g_useMilkdropViz; + + MilkdropWindow_Tick(); + // Swap Uint64 tSwap0 = SDL_GetPerformanceCounter(); #ifndef THESEUS_USE_BGFX @@ -2044,6 +2179,7 @@ int main(int argc, char* argv[]) { ImGui_ImplSDL2_Shutdown(); ImGui::DestroyContext(); + MilkdropWindow_Shutdown(); CleanupApp(); #ifndef THESEUS_USE_BGFX SDL_GL_DeleteContext(g_pGLContext); diff --git a/theseus/desktop/skin_editor.cpp b/theseus/desktop/skin_editor.cpp new file mode 100644 index 0000000..bbf31d3 --- /dev/null +++ b/theseus/desktop/skin_editor.cpp @@ -0,0 +1,511 @@ +// skin_editor.cpp: see skin_editor.h. + +#include "std.h" +#include "dashapp.h" +#include "theseus.h" +#include "settingsfile.h" +#include "skin_assets.h" +#include "skin_editor.h" +#include "imgui.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +extern char g_currentSkin[64]; +extern void SaveDesktopSettings(); +extern void ReloadSkin(); + +// Forward declaration; definition lives after the helpers below. +static bool IsStockReadOnly(); + +// Native OS file picker. Returns empty string on cancel / unsupported. +#if defined(_WIN32) +# include +# include +#endif + +static std::string PickFile() { +#if defined(__APPLE__) + FILE* p = popen( + "osascript -e 'POSIX path of (choose file with prompt \"Select a replacement asset\")' 2>/dev/null", + "r"); + if (!p) return ""; + char buf[2048]; std::string out; + while (fgets(buf, sizeof(buf), p)) out += buf; + pclose(p); + while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) + out.pop_back(); + return out; +#elif defined(_WIN32) + OPENFILENAMEA ofn = {}; + char path[MAX_PATH] = {}; + ofn.lStructSize = sizeof(ofn); + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.lpstrTitle = "Select a replacement asset"; + ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR; + if (GetOpenFileNameA(&ofn)) return std::string(path); + return ""; +#elif defined(__linux__) + FILE* p = popen("zenity --file-selection --title=\"Select a replacement asset\" 2>/dev/null", "r"); + if (!p) return ""; + char buf[2048]; std::string out; + while (fgets(buf, sizeof(buf), p)) out += buf; + pclose(p); + while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) + out.pop_back(); + return out; +#else + return ""; +#endif +} + +// Copy the chosen file into the current skin folder as `slotName`. Backs +// up any existing file at that path to .bak. Returns "" on success +// or an error string. +static std::string ApplyAssetPick(const std::string& srcPath, const char* slotName) { + if (IsStockReadOnly()) return "Stock is read-only. Use 'New from Stock' first."; + if (srcPath.empty() || !slotName || !*slotName) return "Nothing picked."; + fs::path skinDir = fs::path("Data/Skins") / g_currentSkin; + std::error_code ec; + if (!fs::is_directory(skinDir, ec)) + return "Active skin folder is missing."; + fs::path target = skinDir / slotName; + if (fs::exists(target, ec)) { + fs::path backup = skinDir / (std::string(slotName) + ".bak"); + fs::remove(backup, ec); // overwrite any previous backup + fs::rename(target, backup, ec); + if (ec) return std::string("Backup failed: ") + ec.message(); + } + fs::copy_file(srcPath, target, fs::copy_options::overwrite_existing, ec); + if (ec) return std::string("Copy failed: ") + ec.message(); + return ""; +} + +bool g_skinEditorOpen = false; + +static const char* kSkinsRoot = "Data/Skins"; + +// Stock is the canonical reference skin. Refuse to overwrite it so users +// don't trash the fallback by accident. They have to "New from Stock" +// first to get a writable copy. +static bool IsStockReadOnly() { + return strcasecmp(g_currentSkin, "Stock") == 0; +} + +static std::vector ListSkinDirs() { + std::vector out; + std::error_code ec; + if (!fs::is_directory(kSkinsRoot, ec)) return out; + for (const auto& ent : fs::directory_iterator(kSkinsRoot, ec)) { + if (ec) break; + if (!ent.is_directory()) continue; + out.push_back(ent.path().filename().string()); + } + std::sort(out.begin(), out.end(), + [](const std::string& a, const std::string& b) { + // Stock always first; otherwise alphabetical. + if (a == "Stock") return b != "Stock"; + if (b == "Stock") return false; + return strcasecmp(a.c_str(), b.c_str()) < 0; + }); + return out; +} + +struct SkinnableEntry { + const char* name; + CMatInfo* info; + int colorCount; // 1 or 2 +}; + +struct SaveResult { bool ok; std::string msg; }; + +// Write the current in-memory material colors back to .xbx. +// Single-color types land under [Name] Color=R,G,B,A; two-color types +// land under ColorA/ColorB. +static SaveResult SaveSkinColors() { + if (IsStockReadOnly()) + return { false, "Stock is read-only. Use 'New from Stock' first." }; + CSettingsFile* skin = TheseusGetSkinSettings(); + if (!skin) return { false, "Skin settings not loaded." }; + + auto fmt = [](DWORD c, char* buf, size_t n) { + snprintf(buf, n, "%u,%u,%u,%u", + (unsigned)((c >> 16) & 0xFF), + (unsigned)((c >> 8) & 0xFF), + (unsigned) (c & 0xFF), + (unsigned)((c >> 24) & 0xFF)); + }; + + char buf[64]; + int written = 0; + for (int i = 0; i < g_nMatInfoCount; i++) { + CMatInfo* p = g_rgMatInfo[i]; + if (!p || !p->m_name) continue; + int n = MatInfo_ColorCount(p); + if (n == 1) { + fmt(MatInfo_GetColor(p, 0), buf, sizeof(buf)); + skin->SetValue(p->m_name, _T("Color"), buf); + written++; + } else if (n == 2) { + fmt(MatInfo_GetColor(p, 0), buf, sizeof(buf)); + skin->SetValue(p->m_name, _T("ColorA"), buf); + fmt(MatInfo_GetColor(p, 1), buf, sizeof(buf)); + skin->SetValue(p->m_name, _T("ColorB"), buf); + written++; + } + } + if (!skin->Save()) return { false, "Save() failed writing skin file." }; + char done[128]; + snprintf(done, sizeof(done), "Saved %d materials to %s.xbx.", + written, g_currentSkin); + return { true, std::string(done) }; +} + +static std::vector CollectSkinnable() { + std::vector out; + for (int i = 0; i < g_nMatInfoCount; i++) { + CMatInfo* p = g_rgMatInfo[i]; + if (!p) continue; + int n = MatInfo_ColorCount(p); + if (n <= 0) continue; + out.push_back({ p->m_name, p, n }); + } + std::sort(out.begin(), out.end(), + [](const SkinnableEntry& a, const SkinnableEntry& b) { + return strcasecmp(a.name, b.name) < 0; + }); + return out; +} + +// Switch to the given skin, persist the choice, reload assets. +static void ApplySkin(const char* name) { + if (!name || !*name) return; + strncpy(g_currentSkin, name, sizeof(g_currentSkin) - 1); + g_currentSkin[sizeof(g_currentSkin) - 1] = 0; + SaveDesktopSettings(); + ReloadSkin(); +} + +// Copy Stock to a new folder named `newName`, rename the Stock.xbx config +// inside so the loader can find it. Returns empty string on success, an +// error message otherwise. +static std::string CloneStockTo(const std::string& newName) { + if (newName.empty()) return "Name cannot be empty."; + for (char c : newName) { + if (c == '/' || c == '\\' || c == ':' || c == '*' || + c == '?' || c == '"' || c == '<' || c == '>' || c == '|') + return "Name contains an illegal character."; + } + fs::path dst = fs::path(kSkinsRoot) / newName; + std::error_code ec; + if (fs::exists(dst, ec)) return "A skin with that name already exists."; + fs::path src = fs::path(kSkinsRoot) / "Stock"; + if (!fs::is_directory(src, ec)) return "Stock skin folder is missing."; + + fs::copy(src, dst, fs::copy_options::recursive, ec); + if (ec) return std::string("Copy failed: ") + ec.message(); + + // Rename the config file Stock.xbx -> .xbx so InitSkin's + // "{skin}/{skin}.xbx" lookup finds it. + fs::path oldCfg = dst / "Stock.xbx"; + fs::path newCfg = dst / (newName + ".xbx"); + if (fs::exists(oldCfg, ec)) { + fs::rename(oldCfg, newCfg, ec); + if (ec) return std::string("Config rename failed: ") + ec.message(); + } + return ""; +} + +void RenderSkinEditor() { + if (!g_skinEditorOpen) return; + + ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSizeConstraints(ImVec2(360, 0), ImVec2(640, FLT_MAX)); + ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize; + if (!ImGui::Begin("Skin Editor", &g_skinEditorOpen, flags)) { + ImGui::End(); + return; + } + + // Rescan skin folders on demand so creating one in Finder shows up. + static std::vector s_skins; + static double s_lastScan = 0.0; + double now = ImGui::GetTime(); + if (s_skins.empty() || (now - s_lastScan) > 2.0) { + s_skins = ListSkinDirs(); + s_lastScan = now; + } + + ImGui::Spacing(); + ImGui::Text("Active skin"); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.55f, 1.0f, 0.55f, 1.0f), "%s", g_currentSkin); + if (IsStockReadOnly()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.35f, 1.0f), + "(read-only - create a copy below to edit)"); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // Skin picker + int curIdx = 0; + for (size_t i = 0; i < s_skins.size(); i++) { + if (s_skins[i] == g_currentSkin) { curIdx = (int)i; break; } + } + ImGui::Text("Switch to:"); + ImGui::SetNextItemWidth(-1); + if (ImGui::BeginCombo("##skinpicker", + s_skins.empty() ? "(no skins found)" : s_skins[curIdx].c_str())) { + for (size_t i = 0; i < s_skins.size(); i++) { + bool selected = ((int)i == curIdx); + if (ImGui::Selectable(s_skins[i].c_str(), selected)) { + if (s_skins[i] != g_currentSkin) ApplySkin(s_skins[i].c_str()); + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + if (ImGui::Button("Reload current")) + ReloadSkin(); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // New from Stock + static char s_newName[64] = ""; + static std::string s_msg; + static bool s_msgIsError = false; + + ImGui::Text("New from Stock"); + ImGui::SetNextItemWidth(-90); + ImGui::InputTextWithHint("##newname", "skin name", s_newName, sizeof(s_newName)); + ImGui::SameLine(); + if (ImGui::Button("Create", ImVec2(80, 0))) { + s_msg = CloneStockTo(s_newName); + s_msgIsError = !s_msg.empty(); + if (!s_msgIsError) { + ApplySkin(s_newName); + s_msg = std::string("Created and switched to '") + s_newName + "'."; + s_newName[0] = 0; + s_skins = ListSkinDirs(); + } + } + + if (!s_msg.empty()) { + ImGui::Spacing(); + ImVec4 c = s_msgIsError ? ImVec4(1.0f, 0.5f, 0.5f, 1.0f) + : ImVec4(0.55f, 1.0f, 0.55f, 1.0f); + ImGui::TextColored(c, "%s", s_msg.c_str()); + } + + ImGui::Spacing(); + + static std::vector s_entries; + static double s_matLastScan = 0.0; + static char s_matFilter[64] = ""; + + if (s_entries.empty() || (now - s_matLastScan) > 1.0) { + s_entries = CollectSkinnable(); + s_matLastScan = now; + } + + bool matsOpen = ImGui::CollapsingHeader("Materials", + ImGuiTreeNodeFlags_DefaultOpen); + if (!matsOpen) { + ImGui::SameLine(); + ImGui::TextDisabled(" (%d)", (int)s_entries.size()); + } + if (matsOpen) { + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##matfilter", "Filter materials...", + s_matFilter, sizeof(s_matFilter)); + + ImGui::BeginChild("MaterialList", ImVec2(0, 280), true); + int shown = 0; + auto pickerForIdx = [](const SkinnableEntry& e, int idx) { + DWORD c = MatInfo_GetColor(e.info, idx); + float rgba[4]; + rgba[0] = ((c >> 16) & 0xFF) / 255.0f; + rgba[1] = ((c >> 8) & 0xFF) / 255.0f; + rgba[2] = ( c & 0xFF) / 255.0f; + rgba[3] = ((c >> 24) & 0xFF) / 255.0f; + char label[32]; + snprintf(label, sizeof(label), "##c%d_%p", idx, (void*)e.info); + ImGui::SetNextItemWidth(60.0f); + if (ImGui::ColorEdit4(label, rgba, + ImGuiColorEditFlags_NoInputs | + ImGuiColorEditFlags_AlphaPreviewHalf)) { + DWORD nc = + ((DWORD)(rgba[3] * 255.0f + 0.5f) << 24) | + ((DWORD)(rgba[0] * 255.0f + 0.5f) << 16) | + ((DWORD)(rgba[1] * 255.0f + 0.5f) << 8) | + ((DWORD)(rgba[2] * 255.0f + 0.5f)); + MatInfo_SetColor(e.info, idx, nc); + } + }; + + // A material is "active" if its m_lastUsedFrame is within a short window + // of the current frame. Some materials only render on certain scenes. + int curFrame = (g_pD3DDev ? g_pD3DDev->m_frameNumber : 0); + + for (auto& e : s_entries) { + if (s_matFilter[0]) { + const char* hay = e.name ? e.name : ""; + const char* nd = s_matFilter; + bool match = false; + for (const char* p = hay; *p; p++) { + const char* a = p; const char* b = nd; + while (*a && *b && (tolower(*a) == tolower(*b))) { a++; b++; } + if (!*b) { match = true; break; } + } + if (!match) continue; + } + ImGui::PushID(e.info); + + bool active = (e.info->m_lastUsedFrame >= 0) && + (curFrame - e.info->m_lastUsedFrame) < 30; + ImVec4 dotCol = active ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.35f, 0.35f, 0.35f, 1.0f); + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(dotCol, "*"); + ImGui::SameLine(); + ImVec4 nameCol = active ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + ImGui::TextColored(nameCol, "%s", e.name ? e.name : "(unnamed)"); + ImGui::SameLine(200); + pickerForIdx(e, 0); + if (e.colorCount > 1) { + ImGui::SameLine(); + pickerForIdx(e, 1); + } + ImGui::PopID(); + shown++; + } + if (s_entries.empty()) { + ImGui::TextDisabled("(materials not registered yet)"); + } else if (shown == 0) { + ImGui::TextDisabled("(no matches)"); + } + ImGui::EndChild(); + + static std::string s_saveMsg; + static bool s_saveMsgIsError = false; + + ImGui::Spacing(); + if (ImGui::Button("Save Colors to Skin")) { + SaveResult r = SaveSkinColors(); + s_saveMsg = r.msg; + s_saveMsgIsError = !r.ok; + } + ImGui::SameLine(); + ImGui::TextDisabled("%d skinnable materials.", (int)s_entries.size()); + if (!s_saveMsg.empty()) { + ImVec4 c = s_saveMsgIsError ? ImVec4(1.0f, 0.5f, 0.5f, 1.0f) + : ImVec4(0.55f, 1.0f, 0.55f, 1.0f); + ImGui::TextColored(c, "%s", s_saveMsg.c_str()); + } + } // matsOpen + + // ----- Assets section ----- + ImGui::Spacing(); + + static char s_assetFilter[64] = ""; + + int totalRegistered = 0; + for (int i = 0; kSkinnableAssets[i]; i++) totalRegistered++; + + bool assetsOpen = ImGui::CollapsingHeader("Assets"); + if (!assetsOpen) { + ImGui::SameLine(); + ImGui::TextDisabled(" (%d)", totalRegistered); + } + if (assetsOpen) { + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##assetfilter", "Filter assets...", + s_assetFilter, sizeof(s_assetFilter)); + + ImGui::BeginChild("AssetList", ImVec2(0, 220), true); + int shownA = 0; + int totalA = 0; + static std::string s_assetMsg; + static bool s_assetMsgIsError = false; + fs::path skinDir = fs::path(kSkinsRoot) / g_currentSkin; + for (int i = 0; kSkinnableAssets[i]; i++) { + totalA++; + const char* name = kSkinnableAssets[i]; + if (s_assetFilter[0]) { + const char* hay = name; + const char* nd = s_assetFilter; + bool match = false; + for (const char* p = hay; *p; p++) { + const char* a = p; const char* b = nd; + while (*a && *b && (tolower(*a) == tolower(*b))) { a++; b++; } + if (!*b) { match = true; break; } + } + if (!match) continue; + } + std::error_code ec; + bool present = fs::exists(skinDir / name, ec); + + ImGui::PushID(i); + char selLabel[8]; + snprintf(selLabel, sizeof(selLabel), "##sel"); + bool clicked = ImGui::Selectable(selLabel, false, + ImGuiSelectableFlags_AllowItemOverlap, ImVec2(0, 0)); + ImGui::SameLine(0, 0); + ImVec4 dotCol = present ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.35f, 0.35f, 0.35f, 1.0f); + ImGui::TextColored(dotCol, "*"); + ImGui::SameLine(); + ImVec4 nameCol = present ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + ImGui::TextColored(nameCol, "%s", name); + ImGui::SameLine(280); + ImGui::TextDisabled(present ? "(in skin)" : "(not in skin)"); + if (clicked) { + std::string picked = PickFile(); + if (!picked.empty()) { + std::string err = ApplyAssetPick(picked, name); + if (err.empty()) { + s_assetMsg = std::string("Replaced ") + name; + s_assetMsgIsError = false; + ReloadSkin(); + } else { + s_assetMsg = err; + s_assetMsgIsError = true; + } + } + } + ImGui::PopID(); + shownA++; + } + if (totalA == 0) { + ImGui::TextDisabled("(no skinnable assets registered)"); + } else if (shownA == 0) { + ImGui::TextDisabled("(no matches)"); + } + ImGui::EndChild(); + + ImGui::TextDisabled("Click a row to replace the asset in this skin."); + if (!s_assetMsg.empty()) { + ImVec4 c = s_assetMsgIsError ? ImVec4(1.0f, 0.5f, 0.5f, 1.0f) + : ImVec4(0.55f, 1.0f, 0.55f, 1.0f); + ImGui::TextColored(c, "%s", s_assetMsg.c_str()); + } + } // assetsOpen + + ImGui::End(); +} diff --git a/theseus/desktop/skin_editor.h b/theseus/desktop/skin_editor.h new file mode 100644 index 0000000..5ba6edd --- /dev/null +++ b/theseus/desktop/skin_editor.h @@ -0,0 +1,9 @@ +// skin_editor.h: end-user authoring tool for dashboard skins. Lists +// subdirectories of Data/Skins/, applies the chosen skin via ReloadSkin(), +// and clones Stock to seed a new skin. Desktop-only. + +#pragma once + +extern bool g_skinEditorOpen; + +void RenderSkinEditor(); diff --git a/theseus/engine/math_node.cpp b/theseus/engine/math_node.cpp index d8b1000..fafb6e1 100755 --- a/theseus/engine/math_node.cpp +++ b/theseus/engine/math_node.cpp @@ -32,6 +32,7 @@ START_NODE_FUN(CMathClass, CNodeClass) NODE_FUN_NN(sin) NODE_FUN_NN(sqrt) NODE_FUN_NN(tan) + NODE_FUN_NV(projectMEnabled) NODE_FUN_SI(itoa) END_NODE_FUN() #undef _FND_CLASS @@ -79,6 +80,14 @@ float CMathClass::min(float a, float b) { return (a < b) ? a : b; } float CMathClass::random() { return (float)rand() / (float)RAND_MAX; } float CMathClass::round(float n) { return (float)((int)(n + 0.5f)); } +// Desktop-only feature flag readable from XAP scripts so the music scene +// can defer to the projectM overlay rather than running its own fullscreen +// viewpoint switch. Xbox defines g_useMilkdropViz as a constant false. +float CMathClass::projectMEnabled() { + extern bool g_useMilkdropViz; + return g_useMilkdropViz ? 1.0f : 0.0f; +} + // ========================================================================= // Integer to string conversion for XAP scripts // ========================================================================= diff --git a/theseus/engine/runner.h b/theseus/engine/runner.h index aed2e23..3abed3b 100755 --- a/theseus/engine/runner.h +++ b/theseus/engine/runner.h @@ -415,6 +415,7 @@ class CMathClass : public CNodeClass float sin(float number); float sqrt(float number); float tan(float number); + float projectMEnabled(); // returns 1.0 if desktop has g_useMilkdropViz set CStrObject* itoa(int number); float m_E; diff --git a/theseus/render/materials.cpp b/theseus/render/materials.cpp index 336acd5..27181a5 100755 --- a/theseus/render/materials.cpp +++ b/theseus/render/materials.cpp @@ -1021,6 +1021,60 @@ static void UpdateFalloffColor(CMatInfo *p, D3DCOLOR colorSide, D3DCOLOR colorFr pMat->m_colorFront = colorFront; } +// Public introspection / live edit API used by the Skin Editor. +int MatInfo_ColorCount(CMatInfo* p) +{ + if (!p) return 0; + // Two-color types (falloff + everything that inherits it) first; some + // of these are also CInnerWall etc., which are independent two-color + // types, so check those separately. + if (dynamic_cast(p)) return 2; + if (dynamic_cast(p)) return 2; + if (dynamic_cast(p)) return 1; + if (dynamic_cast(p)) return 1; + if (dynamic_cast(p)) return 1; + return 0; +} + +DWORD MatInfo_GetColor(CMatInfo* p, int idx) +{ + if (!p) return 0; + if (CSolidMatInfo* m = dynamic_cast(p)) + return D3DCOLOR_RGBA(m->m_r, m->m_g, m->m_b, m->m_a); + if (CBackingMatInfo* m = dynamic_cast(p)) + return D3DCOLOR_RGBA(m->m_r, m->m_g, m->m_b, m->m_a); + if (CModulateTextureMatInfo* m = dynamic_cast(p)) + return D3DCOLOR_RGBA(m->m_r, m->m_g, m->m_b, m->m_a); + if (CInnerWallMatInfo* m = dynamic_cast(p)) + return (idx == 0) ? m->m_colorSide : m->m_colorFront; + if (CFalloffMatInfo* m = dynamic_cast(p)) + return (idx == 0) ? m->m_colorSide : m->m_colorFront; + return 0; +} + +void MatInfo_SetColor(CMatInfo* p, int idx, DWORD c) +{ + if (!p) return; + BYTE a = (BYTE)((c >> 24) & 0xFF); + BYTE r = (BYTE)((c >> 16) & 0xFF); + BYTE g = (BYTE)((c >> 8) & 0xFF); + BYTE b = (BYTE)( c & 0xFF); + if (CSolidMatInfo* m = dynamic_cast(p)) + { m->m_r=r; m->m_g=g; m->m_b=b; m->m_a=a; return; } + if (CBackingMatInfo* m = dynamic_cast(p)) + { m->m_r=r; m->m_g=g; m->m_b=b; m->m_a=a; return; } + if (CModulateTextureMatInfo* m = dynamic_cast(p)) + { m->m_r=r; m->m_g=g; m->m_b=b; m->m_a=a; return; } + if (CInnerWallMatInfo* m = dynamic_cast(p)) { + if (idx == 0) m->m_colorSide = c; else m->m_colorFront = c; + return; + } + if (CFalloffMatInfo* m = dynamic_cast(p)) { + if (idx == 0) m->m_colorSide = c; else m->m_colorFront = c; + return; + } +} + // Reads a single-color material from the skin file and updates in place static void ReloadSolidMat(CSettingsFile &SkinXBX, const TCHAR *szSection, CustomColor *tmp) { diff --git a/theseus/render/skin_assets.h b/theseus/render/skin_assets.h index 6ac1422..1a0ac06 100644 --- a/theseus/render/skin_assets.h +++ b/theseus/render/skin_assets.h @@ -84,6 +84,36 @@ static inline int SkinCandidatesFor(const char *name, const char **out, int max) // active skin is allowed to override. Case-insensitive on basename. // Members of an equivalence group all hit through their canonical // entry. only one name per group needs to be in the list below. +inline const char *const kSkinnableAssets[] = { + // Textures (.xbx after extension forcing in LoadTexture). + "cellwall.xbx", // shell.xbx aliases here + "dvd_button.xbx", + "DVD_paneltex.xbx", + "dvdaudio.xbx", + "dvdempty.xbx", + "dvdstop.xbx", + "dvdstopw.xbx", + "dvdtitle.xbx", + "dvdunknown.xbx", + "dvdvideo.xbx", + "GameHilite_01.xbx", // menu_hilite / menu_hilight alias here + "menu_hilite.xbx", + "menu_hilight.xbx", + "outline.xbx", + "screenshot.xbx", + "shell.xbx", + "status_gauge.xbx", + "xbox4.xbx", + "xboxlogo.xbx", + "xboxlogo64.xbx", + "xboxlogo128.xbx", + "xboxlogow.xbx", + // Meshes. + "cellwall.xm", // Inner_cell-FACES.xm aliases here + "Inner_cell-FACES.xm", + 0 +}; + static inline bool IsSkinnableAsset(const char *path) { if (!path || !*path) return false; @@ -93,38 +123,8 @@ static inline bool IsSkinnableAsset(const char *path) if (*p == '/' || *p == '\\') base = p + 1; } - static const char *const kSkinnable[] = { - // Textures (.xbx after extension forcing in LoadTexture). - "cellwall.xbx", // shell.xbx aliases here - "dvd_button.xbx", - "DVD_paneltex.xbx", - "dvdaudio.xbx", - "dvdempty.xbx", - "dvdstop.xbx", - "dvdstopw.xbx", - "dvdtitle.xbx", - "dvdunknown.xbx", - "dvdvideo.xbx", - "GameHilite_01.xbx", // menu_hilite / menu_hilight alias here - "menu_hilite.xbx", - "menu_hilight.xbx", - "outline.xbx", - "screenshot.xbx", - "shell.xbx", - "status_gauge.xbx", - "xbox4.xbx", - "xboxlogo.xbx", - "xboxlogo64.xbx", - "xboxlogo128.xbx", - "xboxlogow.xbx", - // Meshes. - "cellwall.xm", // Inner_cell-FACES.xm aliases here - "Inner_cell-FACES.xm", - 0 - }; - - for (int i = 0; kSkinnable[i]; i++) { - if (_stricmp(base, kSkinnable[i]) == 0) return true; + for (int i = 0; kSkinnableAssets[i]; i++) { + if (_stricmp(base, kSkinnableAssets[i]) == 0) return true; } return false; } diff --git a/theseus/render/tmap_system.cpp b/theseus/render/tmap_system.cpp index 118bd7b..9165eca 100644 --- a/theseus/render/tmap_system.cpp +++ b/theseus/render/tmap_system.cpp @@ -17,6 +17,9 @@ #include "asset_loader.h" #include "audio.h" #include "tmap_system.h" +#ifndef _XBOX +#include "milkdrop_window.h" +#endif #define FFT_BUFFER_SIZE_LOG 8 #define FFT_BUFFER_SIZE (1 << FFT_BUFFER_SIZE_LOG) typedef short int sound_sample; @@ -1220,13 +1223,83 @@ void CDynamicTexture::Update() #else D3DLOCKED_RECT lr; VERIFYHR(m_surface->LockRect(0, &lr, NULL, D3DLOCK_DISCARD)); - uint32_t* pDest = (uint32_t*)lr.pBits; - uint8_t* pbSrc = (uint8_t*)m_pSurfx->m_pels; - int nPels = m_size * m_size; - for (int i2 = 0; i2 < nPels; i2 += 1) + uint32_t* pBase = (uint32_t*)lr.pBits; + // Texture is m_nImageWidth wide but we only fill m_size; respect + // the row stride or the data ends up smeared diagonally. + int dstStridePx = (lr.Pitch > 0) ? (lr.Pitch / 4) : m_nImageWidth; + + extern bool g_useMilkdropViz; + bool hasAudioViz = false; + if (g_useMilkdropViz) { + int nChildCount2 = m_children.GetLength(); + for (int ci = 0; ci < nChildCount2; ci++) { + CNode* c = m_children.GetNode(ci); + if (!c) continue; + CNodeClass* nc = c->GetNodeClass(); + if (nc && nc->m_className && + strcmp(nc->m_className, "AudioVisualizer") == 0) + { + hasAudioViz = true; + break; + } + } + } + + int pmW = 0, pmH = 0; + const unsigned char* pmSrc = hasAudioViz + ? MilkdropWindow_GetReadbackRGBA(&pmW, &pmH) : NULL; + + // FRAME-DEBUG: when projectM toggle is on but window not open, cycle a + // solid color tied to TheseusGetNow() so we can SEE per-frame uploads. + if (g_useMilkdropViz && hasAudioViz) + { + if (pmSrc && pmW > 0 && pmH > 0) { + for (int dy = 0; dy < m_size; dy++) { + int sy = (dy * pmH) / m_size; + const unsigned char* row = pmSrc + sy * pmW * 4; + uint32_t* dstRow = pBase + dy * dstStridePx; + for (int dx = 0; dx < m_size; dx++) { + int sx = (dx * pmW) / m_size; + const unsigned char* p = row + sx * 4; + dstRow[dx] = (uint32_t)p[0] + | ((uint32_t)p[1] << 8) + | ((uint32_t)p[2] << 16) + | 0xff000000u; + } + } + } else { + // FRAME-DEBUG: cycle the texture's solid color so we can + // see whether per-frame uploads actually reach the GPU. + // Toggle should be ON but projectM window NOT open to hit this. + extern double TheseusGetNow(); + float t = (float)TheseusGetNow(); + uint8_t r = (uint8_t)(127.5f + 127.5f * sinf(t * 2.0f)); + uint8_t g = (uint8_t)(127.5f + 127.5f * sinf(t * 2.0f + 2.094f)); + uint8_t b = (uint8_t)(127.5f + 127.5f * sinf(t * 2.0f + 4.188f)); + uint32_t v = (uint32_t)r | ((uint32_t)g << 8) | ((uint32_t)b << 16) | 0xff000000u; + for (int dy = 0; dy < m_size; dy++) { + uint32_t* dstRow = pBase + dy * dstStridePx; + for (int dx = 0; dx < m_size; dx++) + dstRow[dx] = v; + } + } + } + else { - uint8_t b = *pbSrc++; - *pDest++ = 0xff000000 | rgpe[b]; + uint8_t* pbSrc = (uint8_t*)m_pSurfx->m_pels; + for (int dy = 0; dy < m_size; dy++) { + uint32_t* dstRow = pBase + dy * dstStridePx; + for (int dx = 0; dx < m_size; dx++) { + uint8_t b = *pbSrc++; + // rgpe[] is D3DCOLOR_ARGB (0xAARRGGBB); convert to + // RGBA byte order for the bgfx-backed texture. + DWORD argb = rgpe[b]; + dstRow[dx] = ((argb >> 16) & 0xff) + | (((argb >> 8) & 0xff) << 8) + | (( argb & 0xff) << 16) + | 0xff000000u; + } + } } VERIFYHR(m_surface->UnlockRect(0)); #endif diff --git a/theseus/shared/theseus.h b/theseus/shared/theseus.h index 4a519a3..898450e 100755 --- a/theseus/shared/theseus.h +++ b/theseus/shared/theseus.h @@ -54,12 +54,23 @@ class CMatInfo const TCHAR *m_name; DWORD m_flags; + + // Frame number of the most recent CMaxMaterial::Render() that bound this + // CMatInfo. -1 = never used. Read by the Skin Editor to highlight which + // materials are currently on screen. + int m_lastUsedFrame = -1; }; extern int g_nMatInfoCount; extern CMatInfo* g_rgMatInfo[240]; extern TCHAR g_szLastLoadedSkin[MAX_PATH]; +// Color introspection / live edit for the Skin Editor. Returns 1 or 2 colors +// per material (or 0 for types without editable colors). D3DCOLOR is ARGB. +int MatInfo_ColorCount(CMatInfo* p); +DWORD MatInfo_GetColor(CMatInfo* p, int idx); +void MatInfo_SetColor(CMatInfo* p, int idx, DWORD c); + class CObject; class CClass; class CInstance; diff --git a/theseus/third-party/projectm b/theseus/third-party/projectm new file mode 160000 index 0000000..4d28493 --- /dev/null +++ b/theseus/third-party/projectm @@ -0,0 +1 @@ +Subproject commit 4d2849333b63235a6af4d1f02508a97529d96dc7 diff --git a/theseus/xbox/main.cpp b/theseus/xbox/main.cpp index 10367e1..9473c09 100755 --- a/theseus/xbox/main.cpp +++ b/theseus/xbox/main.cpp @@ -57,6 +57,7 @@ extern float g_nEffectAlpha; bool g_bWireframe = false; bool g_bMovingScreen = true; +bool g_useMilkdropViz = false; // desktop-only; constant false on Xbox // DVD Closed Captioning stubs (not used). extern "C" void __stdcall D3DDevice_EnableCC(int) {} diff --git a/xips-source/default-desktop/Jellyfin.xap b/xips-source/default-desktop/Jellyfin.xap new file mode 100644 index 0000000..a447aac --- /dev/null +++ b/xips-source/default-desktop/Jellyfin.xap @@ -0,0 +1,450 @@ +// jellyfin.xap: Jellyfin Media Server browser scene (desktop only). +// nJfView -- 0=libraries, 1=items, 2=seasons, 3=episodes. A descends, B +// ascends. Reads from CJellyfinLibrary's cache; sync on sign-in. + + +// ----- state ----- + +var nJfView; // 0=libs, 1=items, 2=seasons, 3=episodes +var nJfCursor; // selected row, absolute (0..count-1) +var nJfScroll; // scroll offset into the list +var nJfVisible; // visible row count (matches MenuButtons size) +var nJfCount; // total rows in the current view +var nJfLastCount; // last count we actually drew; behavior re-renders on mismatch +var nJfCursorBackup0; // saved cursor when drilling lib -> items +var nJfCursorBackup1; // saved cursor when drilling item -> seasons +var nJfCursorBackup2; // saved cursor when drilling season -> episodes + + +function initialize() +{ + nJfView = 0; + nJfCursor = 0; + nJfScroll = 0; + nJfVisible = 8; + nJfCount = 0; + nJfLastCount = -1; + nJfCursorBackup0 = 0; + nJfCursorBackup1 = 0; + nJfCursorBackup2 = 0; +} + + +// Recompute nJfCount from the cache slice for the current view. +function BindActiveList() +{ + if (nJfView == 0) + nJfCount = theJellyfinLibrary.GetCount(); + else if (nJfView == 1) + nJfCount = theJellyfinLibrary.GetItemCount(); + else if (nJfView == 2) + nJfCount = theJellyfinLibrary.GetSeasonCount(); + else + nJfCount = theJellyfinLibrary.GetEpisodeCount(); +} + + +function GetActiveTitle(absIdx) +{ + if (nJfView == 0) + return theJellyfinLibrary.GetName(absIdx); + if (nJfView == 1) + return theJellyfinLibrary.GetItemTitle(absIdx); + if (nJfView == 2) + return theJellyfinLibrary.GetSeasonTitle(absIdx); + return theJellyfinLibrary.GetEpisodeTitle(absIdx); +} + + +// Breadcrumb header. Server -> library -> show -> season as user drills. +function GetHeaderText() +{ + var server = theJellyfinLibrary.GetServerName(); + if (server == "") + server = "PLEX"; + if (nJfView == 0) + return server; + if (nJfView == 1) + return server + " / " + theJellyfinLibrary.GetName(nJfCursorBackup0); + var showTitle = theJellyfinLibrary.GetItemTitle(nJfCursorBackup1); + if (nJfView == 2) + return showTitle; + return showTitle + " / " + theJellyfinLibrary.GetSeasonTitle(nJfCursorBackup2); +} + + +function GetCountText() +{ + if (nJfView == 0) + { + if (nJfCount == 1) return "1 LIBRARY"; + return nJfCount + " LIBRARIES"; + } + if (nJfView == 1) + { + if (nJfCount == 1) return "1 ITEM"; + return nJfCount + " ITEMS"; + } + if (nJfView == 2) + { + if (nJfCount == 1) return "1 SEASON"; + return nJfCount + " SEASONS"; + } + if (nJfCount == 1) return "1 EPISODE"; + return nJfCount + " EPISODES"; +} + + +function StartJellyfinLevel() +{ + var c = theJellyfinMenu.children[0].children[0]; + // Hide CDs 2-5; only MU_cd_01 is repurposed as the poster surface. + c.MU_cd_02.visible = false; + c.MU_cd_03.visible = false; + c.MU_cd_04.visible = false; + c.MU_cd_05.visible = false; + + BindActiveList(); + UpdateJellyfinList(); +} + + +function UpdateJellyfinList() +{ + var c = theJellyfinMenu.children[0].children[0]; + + if (nJfCursor < nJfScroll) + nJfScroll = nJfCursor; + else if (nJfCursor >= nJfScroll + nJfVisible) + nJfScroll = nJfCursor - (nJfVisible - 1); + + for (var i = 0; i < nJfVisible; i = i + 1) + { + var absIdx = nJfScroll + i; + if (absIdx < nJfCount) + { + c.MenuButtons.children[i].visible = true; + c.MenuButtons.children[i].children[1].children[0].geometry.text = GetActiveTitle(absIdx); + c.MenuButtons.children[i].children[0].children[0].appearance.material.name = "GameHilite"; + + if (absIdx == nJfCursor) + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "GameHilite_01.bmp"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "HilightedType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0.1; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 1; + } + else + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "outline.tga"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "NavType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 0; + } + } + else + { + c.MenuButtons.children[i].visible = false; + } + } + + c.MediaHeaderText.text = GetHeaderText(); + c.MusicCountText.text = GetCountText(); + + c.MusicType.text = ""; + c.TimeNum.text = ""; + c.TrackNum.text = ""; + + UpdatePoster(c); + + nJfLastCount = nJfCount; +} + + +// Retexture the orb's top CD (MU_cd_01) with the cursor item's poster. +function UpdatePoster(c) +{ + if (nJfView == 1) + { + theJellyfinLibrary.QueueItemArt(nJfCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = theJellyfinLibrary.GetItemArtPath(nJfCursor); + } + else if (nJfView == 2) + { + theJellyfinLibrary.QueueSeasonArt(nJfCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = theJellyfinLibrary.GetSeasonArtPath(nJfCursor); + } + else if (nJfView == 3) + { + theJellyfinLibrary.QueueEpisodeArt(nJfCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = theJellyfinLibrary.GetEpisodeArtPath(nJfCursor); + } + else + { + c.MU_cd_01.children[0].children[0].appearance.texture.url = "E:\\Jellyfin\\jellyfin_logo.png"; + } +} + + +function EnterLibrary() +{ + theJellyfinLibrary.SetCurrent(nJfCursor); + nJfCursorBackup0 = nJfCursor; + nJfView = 1; + nJfCursor = 0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); +} + + +// Movies/tracks play immediately; TV shows drill into seasons. +function EnterItem() +{ + var type = theJellyfinLibrary.GetItemType(nJfCursor); + if (type == "show") + { + theJellyfinLibrary.DrillIntoShow(nJfCursor); + nJfCursorBackup1 = nJfCursor; + nJfView = 2; + nJfCursor = 0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else + { + theJellyfinLibrary.PlayItem(nJfCursor); + } +} + + +function EnterSeason() +{ + theJellyfinLibrary.DrillIntoSeason(nJfCursor); + nJfCursorBackup2 = nJfCursor; + nJfView = 3; + nJfCursor = 0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); +} + + +function PlayCurrentEpisode() +{ + theJellyfinLibrary.PlayEpisode(nJfCursor); +} + + +// B: pop to parent view (restoring its cursor) or out to main menu. +function GoBackOne() +{ + if (nJfView == 3) + { + nJfView = 2; + nJfCursor = nJfCursorBackup2; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else if (nJfView == 2) + { + nJfView = 1; + nJfCursor = nJfCursorBackup1; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else if (nJfView == 1) + { + nJfView = 0; + nJfCursor = nJfCursorBackup0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else + { + theMainMenu.GoBackTo(); + } +} + + +//////////////////////////////////////////////////////////////////////////// +// theJellyfinMenu Level. Uses jellyfin.xip (clone of mediaplayer chrome). +//////////////////////////////////////////////////////////////////////////// + +DEF theJellyfinMenu Level +{ + archive "jellyfin.xip" + children + [ + Inline + { + url "jellyfin/default.xap" + + function onLoad() + { + StartJellyfinLevel(); + } + } + ] + + shell Transform + { + scale 21.504 21.504 21.504 + translation -44.599998 54.270000 -186.500000 + children + [ + DEF theJellyfinMenuWaver Waver + { + rpm 0.75 + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_01" } + texture ImageTexture + { + alpha true + url "cellwall.bmp" + } + } + geometry Mesh { url "Inner_cell-FACES.xm" } + } + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_02" } + } + geometry Sphere { radius 1 } + } + ] + } + ] + } + + path DEF theJellyfinViewpoint Viewpoint + { + fieldOfView 1.287000 + orientation 0.015660 0.999900 0.001474 -0.187700 + position -47.840000 53.889999 -172.199997 + jump false + } + + control Joystick + { + function OnADown() + { + PlaySoundA(); + + if (theJellyfinLibrary.IsSyncReady() == 0) + { + return; + } + + if (nJfCount <= 0) + return; + + if (nJfView == 0) + EnterLibrary(); + else if (nJfView == 1) + EnterItem(); + else if (nJfView == 2) + EnterSeason(); + else + PlayCurrentEpisode(); + } + + function OnBDown() + { + PlaySoundB(); + GoBackOne(); + } + + function OnMoveUp() + { + BindActiveList(); + + if (nJfCursor <= 0) + { + UpdateJellyfinList(); + return; + } + + PlaySoundMenuChange(); + nJfCursor = nJfCursor - 1; + UpdateJellyfinList(); + } + + function OnMoveDown() + { + BindActiveList(); + + if (nJfCursor >= nJfCount - 1) + { + UpdateJellyfinList(); + return; + } + + PlaySoundMenuChange(); + nJfCursor = nJfCursor + 1; + UpdateJellyfinList(); + } + + function OnXDown() { PlaySoundError(); } + function OnYDown() + { + // Y: modal info with full plot. No-op at library view. + if (nJfView == 0) + { + PlaySoundError(); + return; + } + PlaySoundA(); + var info = theJellyfinLibrary.GetInfoText(nJfView, nJfCursor); + if (info == "") + info = "No additional information."; + TellUser(info, ""); + // Marquee MessageText if the plot overflows -- height must + // be non-zero for the scroll multiplier to kick in. + var mc = theMessageInline.children[0]; + mc.MessageText.height = 6.0; + mc.MessageText.scrollRate = 0.15; + mc.MessageText.scrollDelay = 2; + } + function OnWhiteDown(){ PlaySoundError(); } + function OnBlackDown(){ PlaySoundError(); } + } + + function OnArrival() + { + EnableInput(true); + } + + function OnActivate() + { + CurrentViewpoint = theJellyfinViewpoint; + } + + // Watchdog: async drill data can land after the scene is shown. + // Poll at 2.5Hz, redraw only on count change. + behavior + { + sleep .4; + BindActiveList(); + if (nJfCount != nJfLastCount) + { + UpdateJellyfinList(); + } + } +} + + +DEF theJellyfinLibrary JellyfinLibrary +{ +} diff --git a/xips-source/default-desktop/MediaPlayer.xap b/xips-source/default-desktop/MediaPlayer.xap index 8cdeb24..d3c6e6e 100644 --- a/xips-source/default-desktop/MediaPlayer.xap +++ b/xips-source/default-desktop/MediaPlayer.xap @@ -22,7 +22,7 @@ var nCurMediaItem; var nMediaItemCount; var bInCategoryView; -var nCurCategory; // 0 = Movies, 1 = TV Shows, 2 = Playlists +var nCurCategory; // 0 = Movies, 1 = TV Shows, 2 = Playlists, 3 = Plex (launches standalone scene) var rgCategoryName; var nMediaListScroll; // scroll offset into the (huge) movie/show list var nMediaListVisible; // visible row count (8) @@ -51,6 +51,8 @@ function initialize() rgCategoryName[0] = "Movies"; rgCategoryName[1] = "TV Shows"; rgCategoryName[2] = "Playlists"; + rgCategoryName[3] = "Plex"; + rgCategoryName[4] = "Jellyfin"; bInPlaylistItemView = 0; nPlaylistCursorBackup = 0; @@ -69,7 +71,7 @@ function BindActiveList() { if (bInCategoryView) { - nMediaItemCount = 3; + nMediaItemCount = 5; } else if (nCurCategory == 0) { @@ -79,13 +81,12 @@ function BindActiveList() { nMediaItemCount = theMediaCollection.GetShowCount(); } - else if (bInPlaylistItemView) - { - nMediaItemCount = thePlaylistCollection.GetItemCount(); - } else { - nMediaItemCount = thePlaylistCollection.GetCount(); + if (bInPlaylistItemView) + nMediaItemCount = thePlaylistCollection.GetItemCount(); + else + nMediaItemCount = thePlaylistCollection.GetCount(); } } @@ -167,15 +168,18 @@ function UpdateMediaList() c.MediaHeaderText.text = "MEDIA / TV SHOWS"; c.MusicCountText.text = nMediaItemCount + " SHOWS"; } - else if (bInPlaylistItemView) - { - c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; - c.MusicCountText.text = nMediaItemCount + " ITEMS"; - } else { - c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; - c.MusicCountText.text = nMediaItemCount + " PLAYLISTS"; + if (bInPlaylistItemView) + { + c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; + c.MusicCountText.text = nMediaItemCount + " ITEMS"; + } + else + { + c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; + c.MusicCountText.text = nMediaItemCount + " PLAYLISTS"; + } } } @@ -253,7 +257,17 @@ DEF theMediaMenu Level PlaySoundA(); if (bInCategoryView) { - // Drill into the selected category's item list. + if (nCurMediaItem == 3) + { + GoToPlex(); + return; + } + if (nCurMediaItem == 4) + { + GoToJellyfin(); + return; + } + nCurCategory = nCurMediaItem; bInCategoryView = 0; nCurMediaItem = 0; diff --git a/xips-source/default-desktop/Plex.xap b/xips-source/default-desktop/Plex.xap new file mode 100644 index 0000000..4d61250 --- /dev/null +++ b/xips-source/default-desktop/Plex.xap @@ -0,0 +1,450 @@ +// plex.xap: Plex Media Server browser scene (desktop only). +// nPlexView -- 0=libraries, 1=items, 2=seasons, 3=episodes. A descends, B +// ascends. Reads from CPlexLibrary's cache; sync on sign-in. + + +// ----- state ----- + +var nPlexView; // 0=libs, 1=items, 2=seasons, 3=episodes +var nPlexCursor; // selected row, absolute (0..count-1) +var nPlexScroll; // scroll offset into the list +var nPlexVisible; // visible row count (matches MenuButtons size) +var nPlexCount; // total rows in the current view +var nPlexLastCount; // last count we actually drew; behavior re-renders on mismatch +var nPlexCursorBackup0; // saved cursor when drilling lib -> items +var nPlexCursorBackup1; // saved cursor when drilling item -> seasons +var nPlexCursorBackup2; // saved cursor when drilling season -> episodes + + +function initialize() +{ + nPlexView = 0; + nPlexCursor = 0; + nPlexScroll = 0; + nPlexVisible = 8; + nPlexCount = 0; + nPlexLastCount = -1; + nPlexCursorBackup0 = 0; + nPlexCursorBackup1 = 0; + nPlexCursorBackup2 = 0; +} + + +// Recompute nPlexCount from the cache slice for the current view. +function BindActiveList() +{ + if (nPlexView == 0) + nPlexCount = thePlexLibrary.GetCount(); + else if (nPlexView == 1) + nPlexCount = thePlexLibrary.GetItemCount(); + else if (nPlexView == 2) + nPlexCount = thePlexLibrary.GetSeasonCount(); + else + nPlexCount = thePlexLibrary.GetEpisodeCount(); +} + + +function GetActiveTitle(absIdx) +{ + if (nPlexView == 0) + return thePlexLibrary.GetName(absIdx); + if (nPlexView == 1) + return thePlexLibrary.GetItemTitle(absIdx); + if (nPlexView == 2) + return thePlexLibrary.GetSeasonTitle(absIdx); + return thePlexLibrary.GetEpisodeTitle(absIdx); +} + + +// Breadcrumb header. Server -> library -> show -> season as user drills. +function GetHeaderText() +{ + var server = thePlexLibrary.GetServerName(); + if (server == "") + server = "PLEX"; + if (nPlexView == 0) + return server; + if (nPlexView == 1) + return server + " / " + thePlexLibrary.GetName(nPlexCursorBackup0); + var showTitle = thePlexLibrary.GetItemTitle(nPlexCursorBackup1); + if (nPlexView == 2) + return showTitle; + return showTitle + " / " + thePlexLibrary.GetSeasonTitle(nPlexCursorBackup2); +} + + +function GetCountText() +{ + if (nPlexView == 0) + { + if (nPlexCount == 1) return "1 LIBRARY"; + return nPlexCount + " LIBRARIES"; + } + if (nPlexView == 1) + { + if (nPlexCount == 1) return "1 ITEM"; + return nPlexCount + " ITEMS"; + } + if (nPlexView == 2) + { + if (nPlexCount == 1) return "1 SEASON"; + return nPlexCount + " SEASONS"; + } + if (nPlexCount == 1) return "1 EPISODE"; + return nPlexCount + " EPISODES"; +} + + +function StartPlexLevel() +{ + var c = thePlexMenu.children[0].children[0]; + // Hide CDs 2-5; only MU_cd_01 is repurposed as the poster surface. + c.MU_cd_02.visible = false; + c.MU_cd_03.visible = false; + c.MU_cd_04.visible = false; + c.MU_cd_05.visible = false; + + BindActiveList(); + UpdatePlexList(); +} + + +function UpdatePlexList() +{ + var c = thePlexMenu.children[0].children[0]; + + if (nPlexCursor < nPlexScroll) + nPlexScroll = nPlexCursor; + else if (nPlexCursor >= nPlexScroll + nPlexVisible) + nPlexScroll = nPlexCursor - (nPlexVisible - 1); + + for (var i = 0; i < nPlexVisible; i = i + 1) + { + var absIdx = nPlexScroll + i; + if (absIdx < nPlexCount) + { + c.MenuButtons.children[i].visible = true; + c.MenuButtons.children[i].children[1].children[0].geometry.text = GetActiveTitle(absIdx); + c.MenuButtons.children[i].children[0].children[0].appearance.material.name = "GameHilite"; + + if (absIdx == nPlexCursor) + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "GameHilite_01.bmp"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "HilightedType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0.1; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 1; + } + else + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "outline.tga"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "NavType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 0; + } + } + else + { + c.MenuButtons.children[i].visible = false; + } + } + + c.MediaHeaderText.text = GetHeaderText(); + c.MusicCountText.text = GetCountText(); + + c.MusicType.text = ""; + c.TimeNum.text = ""; + c.TrackNum.text = ""; + + UpdatePoster(c); + + nPlexLastCount = nPlexCount; +} + + +// Retexture the orb's top CD (MU_cd_01) with the cursor item's poster. +function UpdatePoster(c) +{ + if (nPlexView == 1) + { + thePlexLibrary.QueueItemArt(nPlexCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = thePlexLibrary.GetItemArtPath(nPlexCursor); + } + else if (nPlexView == 2) + { + thePlexLibrary.QueueSeasonArt(nPlexCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = thePlexLibrary.GetSeasonArtPath(nPlexCursor); + } + else if (nPlexView == 3) + { + thePlexLibrary.QueueEpisodeArt(nPlexCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = thePlexLibrary.GetEpisodeArtPath(nPlexCursor); + } + else + { + c.MU_cd_01.children[0].children[0].appearance.texture.url = "E:\\Plex\\plex_logo.png"; + } +} + + +function EnterLibrary() +{ + thePlexLibrary.SetCurrent(nPlexCursor); + nPlexCursorBackup0 = nPlexCursor; + nPlexView = 1; + nPlexCursor = 0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); +} + + +// Movies/tracks play immediately; TV shows drill into seasons. +function EnterItem() +{ + var type = thePlexLibrary.GetItemType(nPlexCursor); + if (type == "show") + { + thePlexLibrary.DrillIntoShow(nPlexCursor); + nPlexCursorBackup1 = nPlexCursor; + nPlexView = 2; + nPlexCursor = 0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else + { + thePlexLibrary.PlayItem(nPlexCursor); + } +} + + +function EnterSeason() +{ + thePlexLibrary.DrillIntoSeason(nPlexCursor); + nPlexCursorBackup2 = nPlexCursor; + nPlexView = 3; + nPlexCursor = 0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); +} + + +function PlayCurrentEpisode() +{ + thePlexLibrary.PlayEpisode(nPlexCursor); +} + + +// B: pop to parent view (restoring its cursor) or out to main menu. +function GoBackOne() +{ + if (nPlexView == 3) + { + nPlexView = 2; + nPlexCursor = nPlexCursorBackup2; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else if (nPlexView == 2) + { + nPlexView = 1; + nPlexCursor = nPlexCursorBackup1; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else if (nPlexView == 1) + { + nPlexView = 0; + nPlexCursor = nPlexCursorBackup0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else + { + theMainMenu.GoBackTo(); + } +} + + +//////////////////////////////////////////////////////////////////////////// +// thePlexMenu Level. Uses plex.xip (clone of mediaplayer chrome). +//////////////////////////////////////////////////////////////////////////// + +DEF thePlexMenu Level +{ + archive "plex.xip" + children + [ + Inline + { + url "plex/default.xap" + + function onLoad() + { + StartPlexLevel(); + } + } + ] + + shell Transform + { + scale 21.504 21.504 21.504 + translation -44.599998 54.270000 -186.500000 + children + [ + DEF thePlexMenuWaver Waver + { + rpm 0.75 + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_01" } + texture ImageTexture + { + alpha true + url "cellwall.bmp" + } + } + geometry Mesh { url "Inner_cell-FACES.xm" } + } + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_02" } + } + geometry Sphere { radius 1 } + } + ] + } + ] + } + + path DEF thePlexViewpoint Viewpoint + { + fieldOfView 1.287000 + orientation 0.015660 0.999900 0.001474 -0.187700 + position -47.840000 53.889999 -172.199997 + jump false + } + + control Joystick + { + function OnADown() + { + PlaySoundA(); + + if (thePlexLibrary.IsSyncReady() == 0) + { + return; + } + + if (nPlexCount <= 0) + return; + + if (nPlexView == 0) + EnterLibrary(); + else if (nPlexView == 1) + EnterItem(); + else if (nPlexView == 2) + EnterSeason(); + else + PlayCurrentEpisode(); + } + + function OnBDown() + { + PlaySoundB(); + GoBackOne(); + } + + function OnMoveUp() + { + BindActiveList(); + + if (nPlexCursor <= 0) + { + UpdatePlexList(); + return; + } + + PlaySoundMenuChange(); + nPlexCursor = nPlexCursor - 1; + UpdatePlexList(); + } + + function OnMoveDown() + { + BindActiveList(); + + if (nPlexCursor >= nPlexCount - 1) + { + UpdatePlexList(); + return; + } + + PlaySoundMenuChange(); + nPlexCursor = nPlexCursor + 1; + UpdatePlexList(); + } + + function OnXDown() { PlaySoundError(); } + function OnYDown() + { + // Y: modal info with full plot. No-op at library view. + if (nPlexView == 0) + { + PlaySoundError(); + return; + } + PlaySoundA(); + var info = thePlexLibrary.GetInfoText(nPlexView, nPlexCursor); + if (info == "") + info = "No additional information."; + TellUser(info, ""); + // Marquee MessageText if the plot overflows -- height must + // be non-zero for the scroll multiplier to kick in. + var mc = theMessageInline.children[0]; + mc.MessageText.height = 6.0; + mc.MessageText.scrollRate = 0.15; + mc.MessageText.scrollDelay = 2; + } + function OnWhiteDown(){ PlaySoundError(); } + function OnBlackDown(){ PlaySoundError(); } + } + + function OnArrival() + { + EnableInput(true); + } + + function OnActivate() + { + CurrentViewpoint = thePlexViewpoint; + } + + // Watchdog: async drill data can land after the scene is shown. + // Poll at 2.5Hz, redraw only on count change. + behavior + { + sleep .4; + BindActiveList(); + if (nPlexCount != nPlexLastCount) + { + UpdatePlexList(); + } + } +} + + +DEF thePlexLibrary PlexLibrary +{ +} diff --git a/xips-source/default-desktop/default.xap b/xips-source/default-desktop/default.xap index 9f6f419..9ac334f 100644 --- a/xips-source/default-desktop/default.xap +++ b/xips-source/default-desktop/default.xap @@ -648,6 +648,57 @@ function GoToMedia() //////////////////////////////////////////////////////////////////////////// +DEF thePlexInline Inline +{ + visible false + url "Plex.xap" + + function onLoad() + { + thePlexInline.children[0].thePlexMenu.GoTo(); + } +} + +function GoToPlex() +{ + // Hide theMediaInline so its scene graph doesn't z-fight thePlexMenu. + theMediaInline.visible = false; + if (thePlexInline.visible) + thePlexInline.children[0].thePlexMenu.GoTo(); + else + { + BlockMemoryUnitInsert(); + thePlexInline.visible = true; + } +} + +//////////////////////////////////////////////////////////////////////////// + +DEF theJellyfinInline Inline +{ + visible false + url "Jellyfin.xap" + + function onLoad() + { + theJellyfinInline.children[0].theJellyfinMenu.GoTo(); + } +} + +function GoToJellyfin() +{ + theMediaInline.visible = false; + if (theJellyfinInline.visible) + theJellyfinInline.children[0].theJellyfinMenu.GoTo(); + else + { + BlockMemoryUnitInsert(); + theJellyfinInline.visible = true; + } +} + +//////////////////////////////////////////////////////////////////////////// + DEF theMemoryInline Inline { visible false diff --git a/xips-source/default-desktop/music2.xap b/xips-source/default-desktop/music2.xap index 21803d3..ecdab66 100644 --- a/xips-source/default-desktop/music2.xap +++ b/xips-source/default-desktop/music2.xap @@ -2176,11 +2176,22 @@ DEF theMusicPlayMenu Level { if (theMusicPlayer.transportMode == 1) { - SetVisualizerAlpha(0); - visualizerViewpoint.isBound = true; - theMusicPlayJoystick.enableSecretKey = false; // turn checking off - theScreenSaver.enabled = false; - g_bFullVis = true; + if (Math.projectMEnabled()) + { + // C++ owns the visual via a fullscreen projectM overlay, + // so skip the in-XAP viewpoint switch entirely. + theMusicPlayJoystick.enableSecretKey = false; + theScreenSaver.enabled = false; + g_bFullVis = true; + } + else + { + SetVisualizerAlpha(0); + visualizerViewpoint.isBound = true; + theMusicPlayJoystick.enableSecretKey = false; + theScreenSaver.enabled = false; + g_bFullVis = true; + } } } } diff --git a/xips-source/jellyfin/jellyfin.xap b/xips-source/jellyfin/jellyfin.xap new file mode 100644 index 0000000..a447aac --- /dev/null +++ b/xips-source/jellyfin/jellyfin.xap @@ -0,0 +1,450 @@ +// jellyfin.xap: Jellyfin Media Server browser scene (desktop only). +// nJfView -- 0=libraries, 1=items, 2=seasons, 3=episodes. A descends, B +// ascends. Reads from CJellyfinLibrary's cache; sync on sign-in. + + +// ----- state ----- + +var nJfView; // 0=libs, 1=items, 2=seasons, 3=episodes +var nJfCursor; // selected row, absolute (0..count-1) +var nJfScroll; // scroll offset into the list +var nJfVisible; // visible row count (matches MenuButtons size) +var nJfCount; // total rows in the current view +var nJfLastCount; // last count we actually drew; behavior re-renders on mismatch +var nJfCursorBackup0; // saved cursor when drilling lib -> items +var nJfCursorBackup1; // saved cursor when drilling item -> seasons +var nJfCursorBackup2; // saved cursor when drilling season -> episodes + + +function initialize() +{ + nJfView = 0; + nJfCursor = 0; + nJfScroll = 0; + nJfVisible = 8; + nJfCount = 0; + nJfLastCount = -1; + nJfCursorBackup0 = 0; + nJfCursorBackup1 = 0; + nJfCursorBackup2 = 0; +} + + +// Recompute nJfCount from the cache slice for the current view. +function BindActiveList() +{ + if (nJfView == 0) + nJfCount = theJellyfinLibrary.GetCount(); + else if (nJfView == 1) + nJfCount = theJellyfinLibrary.GetItemCount(); + else if (nJfView == 2) + nJfCount = theJellyfinLibrary.GetSeasonCount(); + else + nJfCount = theJellyfinLibrary.GetEpisodeCount(); +} + + +function GetActiveTitle(absIdx) +{ + if (nJfView == 0) + return theJellyfinLibrary.GetName(absIdx); + if (nJfView == 1) + return theJellyfinLibrary.GetItemTitle(absIdx); + if (nJfView == 2) + return theJellyfinLibrary.GetSeasonTitle(absIdx); + return theJellyfinLibrary.GetEpisodeTitle(absIdx); +} + + +// Breadcrumb header. Server -> library -> show -> season as user drills. +function GetHeaderText() +{ + var server = theJellyfinLibrary.GetServerName(); + if (server == "") + server = "PLEX"; + if (nJfView == 0) + return server; + if (nJfView == 1) + return server + " / " + theJellyfinLibrary.GetName(nJfCursorBackup0); + var showTitle = theJellyfinLibrary.GetItemTitle(nJfCursorBackup1); + if (nJfView == 2) + return showTitle; + return showTitle + " / " + theJellyfinLibrary.GetSeasonTitle(nJfCursorBackup2); +} + + +function GetCountText() +{ + if (nJfView == 0) + { + if (nJfCount == 1) return "1 LIBRARY"; + return nJfCount + " LIBRARIES"; + } + if (nJfView == 1) + { + if (nJfCount == 1) return "1 ITEM"; + return nJfCount + " ITEMS"; + } + if (nJfView == 2) + { + if (nJfCount == 1) return "1 SEASON"; + return nJfCount + " SEASONS"; + } + if (nJfCount == 1) return "1 EPISODE"; + return nJfCount + " EPISODES"; +} + + +function StartJellyfinLevel() +{ + var c = theJellyfinMenu.children[0].children[0]; + // Hide CDs 2-5; only MU_cd_01 is repurposed as the poster surface. + c.MU_cd_02.visible = false; + c.MU_cd_03.visible = false; + c.MU_cd_04.visible = false; + c.MU_cd_05.visible = false; + + BindActiveList(); + UpdateJellyfinList(); +} + + +function UpdateJellyfinList() +{ + var c = theJellyfinMenu.children[0].children[0]; + + if (nJfCursor < nJfScroll) + nJfScroll = nJfCursor; + else if (nJfCursor >= nJfScroll + nJfVisible) + nJfScroll = nJfCursor - (nJfVisible - 1); + + for (var i = 0; i < nJfVisible; i = i + 1) + { + var absIdx = nJfScroll + i; + if (absIdx < nJfCount) + { + c.MenuButtons.children[i].visible = true; + c.MenuButtons.children[i].children[1].children[0].geometry.text = GetActiveTitle(absIdx); + c.MenuButtons.children[i].children[0].children[0].appearance.material.name = "GameHilite"; + + if (absIdx == nJfCursor) + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "GameHilite_01.bmp"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "HilightedType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0.1; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 1; + } + else + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "outline.tga"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "NavType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 0; + } + } + else + { + c.MenuButtons.children[i].visible = false; + } + } + + c.MediaHeaderText.text = GetHeaderText(); + c.MusicCountText.text = GetCountText(); + + c.MusicType.text = ""; + c.TimeNum.text = ""; + c.TrackNum.text = ""; + + UpdatePoster(c); + + nJfLastCount = nJfCount; +} + + +// Retexture the orb's top CD (MU_cd_01) with the cursor item's poster. +function UpdatePoster(c) +{ + if (nJfView == 1) + { + theJellyfinLibrary.QueueItemArt(nJfCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = theJellyfinLibrary.GetItemArtPath(nJfCursor); + } + else if (nJfView == 2) + { + theJellyfinLibrary.QueueSeasonArt(nJfCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = theJellyfinLibrary.GetSeasonArtPath(nJfCursor); + } + else if (nJfView == 3) + { + theJellyfinLibrary.QueueEpisodeArt(nJfCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = theJellyfinLibrary.GetEpisodeArtPath(nJfCursor); + } + else + { + c.MU_cd_01.children[0].children[0].appearance.texture.url = "E:\\Jellyfin\\jellyfin_logo.png"; + } +} + + +function EnterLibrary() +{ + theJellyfinLibrary.SetCurrent(nJfCursor); + nJfCursorBackup0 = nJfCursor; + nJfView = 1; + nJfCursor = 0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); +} + + +// Movies/tracks play immediately; TV shows drill into seasons. +function EnterItem() +{ + var type = theJellyfinLibrary.GetItemType(nJfCursor); + if (type == "show") + { + theJellyfinLibrary.DrillIntoShow(nJfCursor); + nJfCursorBackup1 = nJfCursor; + nJfView = 2; + nJfCursor = 0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else + { + theJellyfinLibrary.PlayItem(nJfCursor); + } +} + + +function EnterSeason() +{ + theJellyfinLibrary.DrillIntoSeason(nJfCursor); + nJfCursorBackup2 = nJfCursor; + nJfView = 3; + nJfCursor = 0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); +} + + +function PlayCurrentEpisode() +{ + theJellyfinLibrary.PlayEpisode(nJfCursor); +} + + +// B: pop to parent view (restoring its cursor) or out to main menu. +function GoBackOne() +{ + if (nJfView == 3) + { + nJfView = 2; + nJfCursor = nJfCursorBackup2; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else if (nJfView == 2) + { + nJfView = 1; + nJfCursor = nJfCursorBackup1; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else if (nJfView == 1) + { + nJfView = 0; + nJfCursor = nJfCursorBackup0; + nJfScroll = 0; + BindActiveList(); + UpdateJellyfinList(); + } + else + { + theMainMenu.GoBackTo(); + } +} + + +//////////////////////////////////////////////////////////////////////////// +// theJellyfinMenu Level. Uses jellyfin.xip (clone of mediaplayer chrome). +//////////////////////////////////////////////////////////////////////////// + +DEF theJellyfinMenu Level +{ + archive "jellyfin.xip" + children + [ + Inline + { + url "jellyfin/default.xap" + + function onLoad() + { + StartJellyfinLevel(); + } + } + ] + + shell Transform + { + scale 21.504 21.504 21.504 + translation -44.599998 54.270000 -186.500000 + children + [ + DEF theJellyfinMenuWaver Waver + { + rpm 0.75 + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_01" } + texture ImageTexture + { + alpha true + url "cellwall.bmp" + } + } + geometry Mesh { url "Inner_cell-FACES.xm" } + } + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_02" } + } + geometry Sphere { radius 1 } + } + ] + } + ] + } + + path DEF theJellyfinViewpoint Viewpoint + { + fieldOfView 1.287000 + orientation 0.015660 0.999900 0.001474 -0.187700 + position -47.840000 53.889999 -172.199997 + jump false + } + + control Joystick + { + function OnADown() + { + PlaySoundA(); + + if (theJellyfinLibrary.IsSyncReady() == 0) + { + return; + } + + if (nJfCount <= 0) + return; + + if (nJfView == 0) + EnterLibrary(); + else if (nJfView == 1) + EnterItem(); + else if (nJfView == 2) + EnterSeason(); + else + PlayCurrentEpisode(); + } + + function OnBDown() + { + PlaySoundB(); + GoBackOne(); + } + + function OnMoveUp() + { + BindActiveList(); + + if (nJfCursor <= 0) + { + UpdateJellyfinList(); + return; + } + + PlaySoundMenuChange(); + nJfCursor = nJfCursor - 1; + UpdateJellyfinList(); + } + + function OnMoveDown() + { + BindActiveList(); + + if (nJfCursor >= nJfCount - 1) + { + UpdateJellyfinList(); + return; + } + + PlaySoundMenuChange(); + nJfCursor = nJfCursor + 1; + UpdateJellyfinList(); + } + + function OnXDown() { PlaySoundError(); } + function OnYDown() + { + // Y: modal info with full plot. No-op at library view. + if (nJfView == 0) + { + PlaySoundError(); + return; + } + PlaySoundA(); + var info = theJellyfinLibrary.GetInfoText(nJfView, nJfCursor); + if (info == "") + info = "No additional information."; + TellUser(info, ""); + // Marquee MessageText if the plot overflows -- height must + // be non-zero for the scroll multiplier to kick in. + var mc = theMessageInline.children[0]; + mc.MessageText.height = 6.0; + mc.MessageText.scrollRate = 0.15; + mc.MessageText.scrollDelay = 2; + } + function OnWhiteDown(){ PlaySoundError(); } + function OnBlackDown(){ PlaySoundError(); } + } + + function OnArrival() + { + EnableInput(true); + } + + function OnActivate() + { + CurrentViewpoint = theJellyfinViewpoint; + } + + // Watchdog: async drill data can land after the scene is shown. + // Poll at 2.5Hz, redraw only on count change. + behavior + { + sleep .4; + BindActiveList(); + if (nJfCount != nJfLastCount) + { + UpdateJellyfinList(); + } + } +} + + +DEF theJellyfinLibrary JellyfinLibrary +{ +} diff --git a/xips-source/jellyfin/jellyfin/MU_GameModule_140-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_GameModule_140-FACES.xm.meta new file mode 100644 index 0000000..9c7f7cf --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_GameModule_140-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=229503 +primitive_count=2040 diff --git a/xips-source/jellyfin/jellyfin/MU_GameModule_142-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_GameModule_142-FACES.xm.meta new file mode 100644 index 0000000..5aa30d3 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_GameModule_142-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=36372 +primitive_count=2040 diff --git a/xips-source/jellyfin/jellyfin/MU_GameModule_144-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_GameModule_144-FACES.xm.meta new file mode 100644 index 0000000..0328d4c --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_GameModule_144-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=117156 +primitive_count=2040 diff --git a/xips-source/jellyfin/jellyfin/MU_GameModule_158-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_GameModule_158-FACES.xm.meta new file mode 100644 index 0000000..f8c38d3 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_GameModule_158-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=228135 +primitive_count=72 diff --git a/xips-source/jellyfin/jellyfin/MU_arm01_inner-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_arm01_inner-FACES.xm.meta new file mode 100644 index 0000000..4e8f1ef --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_arm01_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=81888 +primitive_count=2976 diff --git a/xips-source/jellyfin/jellyfin/MU_arm01_outer-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_arm01_outer-FACES.xm.meta new file mode 100644 index 0000000..9a11dc9 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_arm01_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=228 +primitive_count=3768 diff --git a/xips-source/jellyfin/jellyfin/MU_arm02_inner-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_arm02_inner-FACES.xm.meta new file mode 100644 index 0000000..f67c90e --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_arm02_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=219207 +primitive_count=2976 diff --git a/xips-source/jellyfin/jellyfin/MU_arm02_outer-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_arm02_outer-FACES.xm.meta new file mode 100644 index 0000000..4adc038 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_arm02_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=105852 +primitive_count=3768 diff --git a/xips-source/jellyfin/jellyfin/MU_back_pod_HL-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_back_pod_HL-FACES.xm.meta new file mode 100644 index 0000000..49698e2 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_back_pod_HL-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=235623 +primitive_count=960 diff --git a/xips-source/jellyfin/jellyfin/MU_back_pod_inner-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_back_pod_inner-FACES.xm.meta new file mode 100644 index 0000000..6a1c614 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_back_pod_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=25860 +primitive_count=960 diff --git a/xips-source/jellyfin/jellyfin/MU_back_pod_outer-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_back_pod_outer-FACES.xm.meta new file mode 100644 index 0000000..da622da --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_back_pod_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=90816 +primitive_count=960 diff --git a/xips-source/jellyfin/jellyfin/MU_back_probe-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_back_probe-FACES.xm.meta new file mode 100644 index 0000000..7a665be --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_back_probe-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=189399 +primitive_count=384 diff --git a/xips-source/jellyfin/jellyfin/MU_back_probearm-FACES.xm b/xips-source/jellyfin/jellyfin/MU_back_probearm-FACES.xm new file mode 100644 index 0000000..7435a23 Binary files /dev/null and b/xips-source/jellyfin/jellyfin/MU_back_probearm-FACES.xm differ diff --git a/xips-source/jellyfin/jellyfin/MU_back_probearm-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_back_probearm-FACES.xm.meta new file mode 100644 index 0000000..037fb44 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_back_probearm-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=0 +primitive_count=76 diff --git a/xips-source/jellyfin/jellyfin/MU_back_tube-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_back_tube-FACES.xm.meta new file mode 100644 index 0000000..a548988 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_back_tube-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=250083 +primitive_count=1196 diff --git a/xips-source/jellyfin/jellyfin/MU_backing_cd-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_backing_cd-FACES.xm.meta new file mode 100644 index 0000000..6a17b83 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_backing_cd-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=120 +primitive_count=108 diff --git a/xips-source/jellyfin/jellyfin/MU_backing_cd_01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_backing_cd_01-FACES.xm.meta new file mode 100644 index 0000000..8cb67a0 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_backing_cd_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=444 +primitive_count=108 diff --git a/xips-source/jellyfin/jellyfin/MU_backing_cd_02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_backing_cd_02-FACES.xm.meta new file mode 100644 index 0000000..a7128ad --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_backing_cd_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=2352 +primitive_count=108 diff --git a/xips-source/jellyfin/jellyfin/MU_backing_cd_03-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_backing_cd_03-FACES.xm.meta new file mode 100644 index 0000000..21b3e66 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_backing_cd_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=2028 +primitive_count=108 diff --git a/xips-source/jellyfin/jellyfin/MU_backing_cd_04-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_backing_cd_04-FACES.xm.meta new file mode 100644 index 0000000..f6881c7 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_backing_cd_04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1320 +primitive_count=108 diff --git a/xips-source/jellyfin/jellyfin/MU_backing_cd_05-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_backing_cd_05-FACES.xm.meta new file mode 100644 index 0000000..1ac4f3f --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_backing_cd_05-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1644 +primitive_count=108 diff --git a/xips-source/jellyfin/jellyfin/MU_cd_01-FACES.xm b/xips-source/jellyfin/jellyfin/MU_cd_01-FACES.xm new file mode 100644 index 0000000..63c9a10 Binary files /dev/null and b/xips-source/jellyfin/jellyfin/MU_cd_01-FACES.xm differ diff --git a/xips-source/jellyfin/jellyfin/MU_cd_01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_cd_01-FACES.xm.meta new file mode 100644 index 0000000..92a1c9a --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_cd_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=2 +first_index=0 +primitive_count=6528 diff --git a/xips-source/jellyfin/jellyfin/MU_cd_02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_cd_02-FACES.xm.meta new file mode 100644 index 0000000..fa72124 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_cd_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=126396 +primitive_count=6528 diff --git a/xips-source/jellyfin/jellyfin/MU_cd_03-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_cd_03-FACES.xm.meta new file mode 100644 index 0000000..0f83f1f --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_cd_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=145980 +primitive_count=6528 diff --git a/xips-source/jellyfin/jellyfin/MU_cd_04-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_cd_04-FACES.xm.meta new file mode 100644 index 0000000..689f112 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_cd_04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=62304 +primitive_count=6528 diff --git a/xips-source/jellyfin/jellyfin/MU_cd_05-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_cd_05-FACES.xm.meta new file mode 100644 index 0000000..598214a --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_cd_05-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=42720 +primitive_count=6528 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a-FACES.xm.meta new file mode 100644 index 0000000..29f1f0b --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=888 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a01-FACES.xm.meta new file mode 100644 index 0000000..be447ef --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=948 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a02-FACES.xm.meta new file mode 100644 index 0000000..ae8677f --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=60 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a03-FACES.xm b/xips-source/jellyfin/jellyfin/MU_menubutton_02a03-FACES.xm new file mode 100644 index 0000000..5d95a38 Binary files /dev/null and b/xips-source/jellyfin/jellyfin/MU_menubutton_02a03-FACES.xm differ diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a03-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a03-FACES.xm.meta new file mode 100644 index 0000000..905f280 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=0 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a04-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a04-FACES.xm.meta new file mode 100644 index 0000000..1fe80ae --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=768 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a05-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a05-FACES.xm.meta new file mode 100644 index 0000000..81f817f --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a05-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=828 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02a06-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02a06-FACES.xm.meta new file mode 100644 index 0000000..e8315b5 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02a06-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1968 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_menubutton_02b-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_menubutton_02b-FACES.xm.meta new file mode 100644 index 0000000..8ef28d0 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_menubutton_02b-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=273153 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_morearrow_down-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_morearrow_down-FACES.xm.meta new file mode 100644 index 0000000..8c4bf76 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_morearrow_down-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=289839 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_morearrow_up-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_morearrow_up-FACES.xm.meta new file mode 100644 index 0000000..049a159 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_morearrow_up-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=289899 +primitive_count=20 diff --git a/xips-source/jellyfin/jellyfin/MU_musicpod-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_musicpod-FACES.xm.meta new file mode 100644 index 0000000..afde84e --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_musicpod-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=93900 +primitive_count=3024 diff --git a/xips-source/jellyfin/jellyfin/MU_panel_backing-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_panel_backing-FACES.xm.meta new file mode 100644 index 0000000..352dd38 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_panel_backing-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1008 +primitive_count=104 diff --git a/xips-source/jellyfin/jellyfin/MU_panel_meta-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_panel_meta-FACES.xm.meta new file mode 100644 index 0000000..a888557 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_panel_meta-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=93696 +primitive_count=68 diff --git a/xips-source/jellyfin/jellyfin/MU_panel_support-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_panel_support-FACES.xm.meta new file mode 100644 index 0000000..38b226c --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_panel_support-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=32940 +primitive_count=84 diff --git a/xips-source/jellyfin/jellyfin/MU_panel_support_01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_panel_support_01-FACES.xm.meta new file mode 100644 index 0000000..6e24ecb --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_panel_support_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=123276 +primitive_count=532 diff --git a/xips-source/jellyfin/jellyfin/MU_panel_support_02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_panel_support_02-FACES.xm.meta new file mode 100644 index 0000000..a1eb3de --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_panel_support_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=289959 +primitive_count=36 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_shell_01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_shell_01-FACES.xm.meta new file mode 100644 index 0000000..5e15830 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_shell_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=287541 +primitive_count=766 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_shell_02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_shell_02-FACES.xm.meta new file mode 100644 index 0000000..e53e6e0 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_shell_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=125622 +primitive_count=258 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_shell_03-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_shell_03-FACES.xm.meta new file mode 100644 index 0000000..9e64ebc --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_shell_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=124872 +primitive_count=250 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_support_01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_support_01-FACES.xm.meta new file mode 100644 index 0000000..1e90c4c --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_support_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=273213 +primitive_count=4776 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_support_02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_support_02-FACES.xm.meta new file mode 100644 index 0000000..80c99c6 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_support_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=204879 +primitive_count=4776 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_support_03-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_support_03-FACES.xm.meta new file mode 100644 index 0000000..ba06439 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_support_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=190551 +primitive_count=4776 diff --git a/xips-source/jellyfin/jellyfin/MU_pod_support_04-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_pod_support_04-FACES.xm.meta new file mode 100644 index 0000000..c62c3cd --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_pod_support_04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=11532 +primitive_count=4776 diff --git a/xips-source/jellyfin/jellyfin/MU_podsocket_inner-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_podsocket_inner-FACES.xm.meta new file mode 100644 index 0000000..26d4af5 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_podsocket_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=35052 +primitive_count=440 diff --git a/xips-source/jellyfin/jellyfin/MU_podsocket_outer-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_podsocket_outer-FACES.xm.meta new file mode 100644 index 0000000..0a5d3e1 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_podsocket_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=31620 +primitive_count=440 diff --git a/xips-source/jellyfin/jellyfin/MU_select_pod_HL-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_select_pod_HL-FACES.xm.meta new file mode 100644 index 0000000..d976ac4 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_select_pod_HL-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=253671 +primitive_count=960 diff --git a/xips-source/jellyfin/jellyfin/MU_select_pod_inner-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_select_pod_inner-FACES.xm.meta new file mode 100644 index 0000000..db477b3 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_select_pod_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=28740 +primitive_count=960 diff --git a/xips-source/jellyfin/jellyfin/MU_select_pod_outer-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_select_pod_outer-FACES.xm.meta new file mode 100644 index 0000000..988b0e3 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_select_pod_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=102972 +primitive_count=960 diff --git a/xips-source/jellyfin/jellyfin/MU_select_probe-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_select_probe-FACES.xm.meta new file mode 100644 index 0000000..c23358b --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_select_probe-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=228351 +primitive_count=384 diff --git a/xips-source/jellyfin/jellyfin/MU_select_probearm-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_select_probearm-FACES.xm.meta new file mode 100644 index 0000000..e096e5a --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_select_probearm-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=42492 +primitive_count=76 diff --git a/xips-source/jellyfin/jellyfin/MU_select_tube-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_select_tube-FACES.xm.meta new file mode 100644 index 0000000..d94b141 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_select_tube-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=246495 +primitive_count=1196 diff --git a/xips-source/jellyfin/jellyfin/MU_tube_01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_tube_01-FACES.xm.meta new file mode 100644 index 0000000..b539635 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_tube_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=33192 +primitive_count=620 diff --git a/xips-source/jellyfin/jellyfin/MU_tube_02-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_tube_02-FACES.xm.meta new file mode 100644 index 0000000..20cc342 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_tube_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=238503 +primitive_count=620 diff --git a/xips-source/jellyfin/jellyfin/MU_tube_03-FACES.xm.meta b/xips-source/jellyfin/jellyfin/MU_tube_03-FACES.xm.meta new file mode 100644 index 0000000..1398c05 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/MU_tube_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=240363 +primitive_count=2044 diff --git a/xips-source/jellyfin/jellyfin/S_Vid__tv_tv01-FACES.xm.meta b/xips-source/jellyfin/jellyfin/S_Vid__tv_tv01-FACES.xm.meta new file mode 100644 index 0000000..724423e --- /dev/null +++ b/xips-source/jellyfin/jellyfin/S_Vid__tv_tv01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=165564 +primitive_count=7945 diff --git a/xips-source/jellyfin/jellyfin/S_Vid__widescreen_tv-FACES.xm.meta b/xips-source/jellyfin/jellyfin/S_Vid__widescreen_tv-FACES.xm.meta new file mode 100644 index 0000000..72ff656 --- /dev/null +++ b/xips-source/jellyfin/jellyfin/S_Vid__widescreen_tv-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=256551 +primitive_count=5534 diff --git a/xips-source/jellyfin/jellyfin/cd_opacity.xbx b/xips-source/jellyfin/jellyfin/cd_opacity.xbx new file mode 100644 index 0000000..cad0643 Binary files /dev/null and b/xips-source/jellyfin/jellyfin/cd_opacity.xbx differ diff --git a/xips-source/jellyfin/jellyfin/default.xap b/xips-source/jellyfin/jellyfin/default.xap new file mode 100644 index 0000000..73634aa --- /dev/null +++ b/xips-source/jellyfin/jellyfin/default.xap @@ -0,0 +1,1979 @@ +DEF Music_Arm Transform +{ + children + [ + + DEF orb Transform + { + children + [ + DEF MU_backing_cd Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "DarkenBacking" + } + } + geometry DEF MU_backing_cd-FACES Mesh { url "MU_backing_cd-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.512600 0.512600 0.512600 + scaleOrientation 1.000000 0.000000 0.000000 0.000000 + translation -0.248800 -0.664600 -1.210000 + } + DEF MU_pod_support_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_03-FACES Mesh { url "MU_pod_support_03-FACES.xm" } + } + ] + rotation -0.923900 0.382700 0.000000 -3.142000 + scale 0.182900 0.182900 0.182900 + scaleOrientation 0.558100 -0.066720 0.827100 -0.205200 + translation -5.110000 1.530000 -2.960000 + } + DEF MU_arm01_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Cell_Light/LegSkin" + } + } + geometry DEF MU_arm01_outer-FACES Mesh { url "MU_arm01_outer-FACES.xm" } + } + ] + rotation -0.357400 0.862900 -0.357400 -1.718000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.479300 0.129100 0.868100 -0.737400 + translation 3.795000 2.995000 2.039000 + } + DEF MU_arm01_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_arm01_inner-FACES Mesh { url "MU_arm01_inner-FACES.xm" } + } + ] + rotation -0.357400 0.862900 -0.357400 -1.718000 + scale 0.404400 0.404400 0.404400 + scaleOrientation 0.999100 0.041310 0.000000 -0.170000 + translation 3.652000 3.138000 2.038000 + } + DEF MU_pod_shell_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_pod_shell_01-FACES Mesh { url "MU_pod_shell_01-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation -1.559000 -0.664600 0.863400 + } + DEF MU_pod_shell_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_pod_shell_02-FACES Mesh { url "MU_pod_shell_02-FACES.xm" } + } + ] + rotation -0.647700 0.538700 0.538700 -1.992000 + scale 0.465000 0.465000 0.465000 + translation -1.559000 -0.664600 0.863400 + } + DEF MU_pod_shell_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_pod_shell_03-FACES Mesh { url "MU_pod_shell_03-FACES.xm" } + } + ] + rotation -0.074120 0.705200 0.705200 -2.994000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.870700 -0.449300 0.199900 -0.271600 + translation -1.559000 -0.664600 0.863400 + } + DEF MU_pod_support_04 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_04-FACES Mesh { url "MU_pod_support_04-FACES.xm" } + } + ] + rotation 0.382700 -0.923900 0.000000 -3.142000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.700500 -0.712900 -0.033920 -0.080940 + translation 0.330300 2.914000 -2.960000 + } + /* DEF MU_musicpod Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GamePod" + } + } + geometry DEF MU_musicpod-FACES Mesh { url "MU_musicpod-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -1.571000 + scale 0.418900 0.418900 0.418900 + translation -1.727000 -0.832200 0.695800 + } + */ + DEF MU_pod_support_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_01-FACES Mesh { url "MU_pod_support_01-FACES.xm" } + } + ] + rotation 0.000000 0.000000 1.000000 -0.785400 + scale 0.182900 0.182900 0.182900 + scaleOrientation 1.000000 0.000000 0.000000 0.000000 + translation 0.330300 -3.911000 -2.960000 + } + DEF MU_podsocket_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_podsocket_inner-FACES Mesh { url "MU_podsocket_inner-FACES.xm" } + } + ] + rotation 0.862900 -0.357400 0.357400 -1.718000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.434200 -0.818000 -0.377200 -0.190100 + translation -1.796000 -0.640000 -3.240000 + } + DEF MU_pod_support_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_02-FACES Mesh { url "MU_pod_support_02-FACES.xm" } + } + ] + rotation 0.000000 0.000000 1.000000 -2.356000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.107900 0.554400 -0.825200 -0.106600 + translation -5.110000 -2.527000 -2.960000 + } + DEF MU_podsocket_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_podsocket_outer-FACES Mesh { url "MU_podsocket_outer-FACES.xm" } + } + ] + rotation 0.862900 -0.357400 0.357400 -1.718000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.434200 -0.818000 -0.377200 -0.190100 + translation -1.796000 -0.640000 -3.240000 + } + DEF MU_arm02_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_arm02_inner-FACES Mesh { url "MU_arm02_inner-FACES.xm" } + } + ] + rotation -0.663600 -0.345400 -0.663600 -3.807000 + scale 0.404400 0.404400 0.404400 + scaleOrientation 0.786300 -0.048550 -0.615900 -0.413500 + translation -4.386000 -3.954000 2.038000 + } + DEF MU_arm02_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Cell_Light/LegSkin" + } + } + geometry DEF MU_arm02_outer-FACES Mesh { url "MU_arm02_outer-FACES.xm" } + } + ] + rotation -0.663600 -0.345400 -0.663600 -3.807000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.129900 0.978100 -0.162800 -0.373900 + translation -4.552000 -3.837000 2.449000 + } + DEF MU_tube_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Tubes" + } + } + geometry DEF MU_tube_02-FACES Mesh { url "MU_tube_02-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 1.212000 5.402000 1.664000 + } + DEF MU_tube_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Tubes" + } + } + geometry DEF MU_tube_01-FACES Mesh { url "MU_tube_01-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 0.067240 5.402000 1.664000 + } + DEF MU_tube_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Tubes" + } + } + geometry DEF MU_tube_03-FACES Mesh { url "MU_tube_03-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 3.058000 -1.425000 2.058000 + } + DEF MU_GameModule_158 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_GameModule_158-FACES Mesh { url "MU_GameModule_158-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 3.721000 6.288000 2.808000 + } + DEF MU_tube_attachment_03 Transform + { + children + [ + DEF MU_GameModule_142 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_GameModule_142-FACES Mesh { url "MU_GameModule_142-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -0.130900 + translation -2.746000 -1.800000 22.650000 + } + ] + rotation -0.792900 0.608400 0.034620 -3.195000 + scale 0.174200 0.174200 0.174200 + scaleOrientation -0.039380 -0.053480 -0.997800 -0.152800 + translation 0.179000 -5.581000 4.294000 + } + DEF MU_tube_attachment_02 Transform + { + children + [ + DEF MU_GameModule_144 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_GameModule_144-FACES Mesh { url "MU_GameModule_144-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -0.130900 + translation -2.746000 -1.800000 22.650000 + } + ] + rotation -0.781900 -0.622000 0.041830 -3.117000 + scale 0.174200 0.174200 0.174200 + scaleOrientation 0.014660 0.521200 -0.853300 -0.277400 + translation -0.057290 4.140000 4.319000 + } + DEF MU_tube_attachment_01 Transform + { + children + [ + DEF MU_GameModule_140 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_GameModule_140-FACES Mesh { url "MU_GameModule_140-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -0.130900 + translation -2.746000 -1.800000 22.650000 + } + ] + rotation -0.736900 -0.675200 0.032170 -3.083000 + scale 0.174200 0.174200 0.174200 + scaleOrientation 0.141900 -0.517100 0.844100 -0.168000 + translation -1.287000 4.151000 4.322000 + } + ] + translation -41.630001 55.200001 -185.699997 + } + + Transform + { + children + [ + // This is the icon (a collapsed stack of CD's that can be expanded) + Transform + { + scale 2.5 2.5 2.5 + translation -1.68 -2 -2.5 + children + [ + Waver + { + rpm 10 + axis 0 1 0 + children + [ + /*DEF MU_backing_cd_05 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_05-FACES Mesh { url "MU_backing_cd_05-FACES.xm" } + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.438400 0.438400 0.438400 + scaleOrientation 0.694500 0.128700 0.707900 -1.001000 + translation 0.003821 -0.020290 -0.017460 + }*/ + DEF MU_cd_05 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.tga" + } + } + geometry DEF MU_cd_05-FACES Mesh { url "MU_cd_05-FACES.xm" } + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation -0.193000 0.354100 0.915100 -0.135400 + translation -0.015810 -0.030690 -0.039800 + } + + + DEF Group04 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_04 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + //alpha true + } + } + geometry DEF MU_backing_cd_04-FACES Mesh { url "MU_backing_cd_04-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.189999 -53.320000 183.300003 + }*/ + DEF MU_cd_04 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry DEF MU_cd_04-FACES Mesh { url "MU_cd_04-FACES.xm" } + } + ] + translation 43.119999 -53.389999 183.300003 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.430000 55.830002 -30.420000 + // translation 12.440000 56.130001 -30.120001 + } + + + DEF Group03 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_03-FACES Mesh { url "MU_backing_cd_03-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.180000 -52.259998 183.199997 + }*/ + DEF MU_cd_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry DEF MU_cd_03-FACES Mesh { url "MU_cd_03-FACES.xm" } + } + ] + translation 43.119999 -52.330002 183.199997 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.360000 55.500000 -30.600000 + // translation 12.390000 56.110001 -29.980000 + } + DEF Group02 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_02-FACES Mesh { url "MU_backing_cd_02-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.169998 -51.180000 182.800003 + }*/ + DEF MU_cd_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry DEF MU_cd_02-FACES Mesh { url "MU_cd_02-FACES.xm" } + } + ] + translation 43.110001 -51.259998 182.800003 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.310000 55.189999 -30.780001 + // translation 12.350000 56.110001 -29.830000 + } + + DEF Group01 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_01-FACES Mesh { url "MU_backing_cd_01-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.160000 -50.110001 182.500000 + }*/ + DEF MU_cd_01 Transform + { + children + [ + Transform + { + // Inherit Group01's chain for the base tilt; + // add a small X-axis rotation to lean the top + // back into the scene a touch. Negative Y on + // the scale keeps the texture right-side up. + rotation 1 0 -1 -0.4 + scale 10.0 -15.0 0.5 + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry Box + } + ] + } + ] + translation 43.090000 -50.180000 182.500000 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.260000 54.869999 -30.950001 + // translation 12.310000 56.119999 -29.680000 + } + ] + } + ] + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 55.090000 -186.000000 + } + + DEF actual_orb Transform + { + children + [ + DEF MU_musicpod Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GamePod" + } + } + geometry DEF MU_musicpod-FACES Mesh { url "MU_musicpod-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -1.571000 + scale 0.418900 0.418900 0.418900 + translation -1.727000 -0.832200 0.695800 + } + ] + translation -41.630001 55.200001 -185.699997 + } + ] + translation 0.5 0.0 0.0 +} + + +DEF MU_panel_backing Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_03" + } + texture ImageTexture + { + url "panel4.tga" + } + } + geometry DEF MU_panel_backing-FACES Mesh { url "MU_panel_backing-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -46.240002 55.180000 -180.500000 +} + + +DEF MU_panel_meta Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_meta-FACES Mesh { url "MU_panel_meta-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -47.090000 51.290001 -180.399994 +} + +DEF MU_panel_support Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_support-FACES Mesh { url "MU_panel_support-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.067560 0.683400 -0.726900 -0.002854 + translation -46.150002 57.490002 -180.399994 +} + +DEF MenuButtons Group +{ + children + [ + DEF MU_menubutton_02b Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02b-FACES Mesh { url "MU_menubutton_02a-FACES.xm" } + //{ url "MU_menubutton_02b-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.1 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -9 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.091940 0.559900 0.823500 -0.000926 + translation -47.209999 56.889999 -180.300003 + } + DEF MU_menubutton_01 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a-FACES Mesh { url "MU_menubutton_02a-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.1 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 56.209999 -180.300003 + } + DEF MU_menubutton_02 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a01-FACES Mesh { url "MU_menubutton_02a01-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.1 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 55.520000 -180.300003 + } + DEF MU_menubutton_03 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a02-FACES Mesh { url "MU_menubutton_02a02-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.14 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 54.840000 -180.300003 + } + DEF MU_menubutton_04 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a03-FACES Mesh { url "MU_menubutton_02a03-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.2 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 54.160000 -180.300003 + } + DEF MU_menubutton_05 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a04-FACES Mesh { url "MU_menubutton_02a04-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.18 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 53.480000 -180.300003 + } + DEF MU_menubutton_06 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a05-FACES Mesh { url "MU_menubutton_02a05-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.2 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 52.799999 -180.300003 + } + DEF MU_menubutton_07 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a06-FACES Mesh { url "MU_menubutton_02a06-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.25 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -9 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 52.119999 -180.300003 + } + ] +} + + +DEF MU_text_musicCollection Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MediaHeaderText Text { font "Heading" text "MEDIA" } + } + ] + //rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.7 0.7 0.0 + //scaleOrientation -0.040360 0.530400 0.846800 -0.004249 + translation -50.770000 57.649998 -180.199997 +} + +DEF MU_panel_support_01 Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_support_01-FACES Mesh { url "MU_panel_support_01-FACES.xm" } + } + ] + rotation -0.566400 0.566400 0.598600 -2.063000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.853300 0.476500 -0.211500 -0.295500 + translation -50.680000 53.520000 -180.199997 +} + +DEF MU_panel_support_02 Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_support_02-FACES Mesh { url "MU_panel_support_02-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -47.090000 51.290001 -180.399994 +} + +DEF MU_morearrow_down Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MU_morearrow_down-FACES Mesh { url "MU_morearrow_down-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -1.000000 0.006956 -0.003303 -0.292700 + translation -46.360001 52.200001 -180.199997 +} + +DEF MU_morearrow_up Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MU_morearrow_up-FACES Mesh { url "MU_morearrow_up-FACES.xm" } + } + ] + rotation 0.019530 -0.707000 0.707000 -3.181000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.954000 -0.028350 -0.298400 -0.143200 + translation -46.360001 56.840000 -180.199997 +} + +DEF Group06 Transform +{ + children + [ + DEF Group06-TIMER TimeSensor + { + cycleInterval 14.300000 + loop TRUE + } + DEF MU_back_probearm Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_back_probearm-FACES Mesh { url "MU_back_probearm-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 20.680000 14.960000 6.071000 + } + DEF MU_back_probe Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_back_probe-FACES Mesh { url "MU_back_probe-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 19.790001 14.110000 6.235000 + } + DEF MU_back_pod_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_back_pod_inner-FACES Mesh { url "MU_back_pod_inner-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -11.290000 3.150000 + } + DEF MU_Text_b Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry Text { font "heading" justify "middle" translate false text "B" } + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.605000 0.605000 0.000 + //scaleOrientation 0.000000 0.000000 1.000000 -0.005973 + translation 1.889000 2.422000 -0.409200 + } + DEF MU_Text_back Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "XBOXgreen" + } + } + geometry Text { font "Body" text "BACK" justify "end"} + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.375000 0.475000 0.00 + //scaleOrientation 0.000000 0.000000 1.000000 -0.005973 + translation 1.183300 2.156 -0.409200 + } + // Y cluster -- mirror of B's probearm + probe + pod + tube + + // label, shifted down by ~1.6 units so it forms a second row + // below B/BACK. Reuses the same mesh URLs since they're the + // same physical button parts. delta_y vs B = -1.622. + DEF MU_y_probearm Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "FlatSurfaces" } } + geometry Mesh { url "MU_back_probearm-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 20.680000 13.660000 6.071000 + } + DEF MU_y_probe Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "FlatSurfaces" } } + geometry Mesh { url "MU_back_probe-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 19.790001 12.810000 6.235000 + } + DEF MU_y_pod_inner Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "CellEgg/Parts" } } + geometry Mesh { url "MU_back_pod_inner-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -12.590000 3.150000 + } + DEF MU_y_pod_outer Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "CellEgg/Parts" } } + geometry Mesh { url "MU_back_pod_outer-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -12.590000 3.150000 + } + DEF MU_y_pod_HL Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "EggGlow" } } + geometry Mesh { url "MU_back_pod_HL-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -12.590000 3.150000 + } + DEF MU_y_tube Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "TubesFade" } } + geometry Mesh { url "MU_back_tube-FACES.xm" } + } + ] + rotation 0.990900 -0.095410 -0.095410 -1.580000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.010380 -0.285600 0.958300 -0.080610 + translation -22.030001 -29.050000 9.817000 + } + DEF MU_Text_y Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "NavType" } } + geometry Text { font "heading" justify "middle" translate false text "Y" } + } + ] + scale 0.605000 0.605000 0.000 + translation 1.889000 1.122000 -0.409200 + } + DEF MU_Text_info Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "XBOXgreen" } } + geometry Text { font "Body" text "INFO" justify "end" } + } + ] + scale 0.375000 0.475000 0.00 + translation 1.183300 0.856000 -0.409200 + } + DEF MU_back_pod_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_back_pod_outer-FACES Mesh { url "MU_back_pod_outer-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -11.290000 3.150000 + } + DEF MU_back_tube Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "TubesFade" + } + } + geometry DEF MU_back_tube-FACES Mesh { url "MU_back_tube-FACES.xm" } + } + ] + rotation 0.990900 -0.095410 -0.095410 -1.580000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.010380 -0.285600 0.958300 -0.080610 + translation -22.030001 -27.750000 9.817000 + } + DEF MU_back_pod_HL Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "EggGlow" + } + } + geometry DEF MU_back_pod_HL-FACES Mesh { url "MU_back_pod_HL-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -11.290000 3.150000 + } + ] + rotation 0.000000 -1.000000 0.000000 -0.034910 + translation -52.740002 46.379999 -181.399994 +} +DEF Group07 Transform +{ + children + [ + DEF Group07-TIMER TimeSensor + { + cycleInterval 14.300000 + loop TRUE + } + DEF MU_Text_a Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry Text { font "heading" justify "middle" translate false text "A" } + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.605000 0.605000 0.000 + //scaleOrientation 0.000000 0.000000 -1.000000 -0.045050 + translation -1.959000 2.391000 -0.443200 + } + DEF MU_select_probearm Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_select_probearm-FACES Mesh { url "MU_select_probearm-FACES.xm" } + } + ] + rotation -0.786500 0.499900 0.362600 -1.910000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.000000 0.005536 1.000000 -0.474500 + translation -9.489000 15.920000 6.071000 + } + DEF MU_text_select Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "XBOXgreen" + } + } + geometry Text { font "Body" text "SELECT" } + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.375000 0.475000 0.00 + //scaleOrientation 0.000000 0.000000 -1.000000 -0.097740 + translation -1.238900 2.156000 -0.409300 + } + DEF MU_select_probe Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_select_probe-FACES Mesh { url "MU_select_probe-FACES.xm" } + } + ] + rotation -0.786500 0.499900 0.362600 -1.910000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.000000 0.005536 1.000000 -0.474500 + translation -8.603000 15.060000 6.235000 + } + DEF MU_select_pod_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_select_pod_inner-FACES Mesh { url "MU_select_pod_inner-FACES.xm" } + } + ] + scale 0.465000 0.465000 0.465000 + translation 0.178000 -10.330000 3.150000 + } + DEF MU_select_pod_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_select_pod_outer-FACES Mesh { url "MU_select_pod_outer-FACES.xm" } + } + ] + scale 0.465000 0.465000 0.465000 + translation 0.178000 -10.330000 3.150000 + } + DEF MU_select_tube Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "TubesFade" + } + } + geometry DEF MU_select_tube-FACES Mesh { url "MU_select_tube-FACES.xm" } + } + ] + rotation -0.990900 0.095410 -0.095410 -1.580000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.011110 0.412400 0.911000 -0.088980 + translation 33.220001 -26.790001 9.817000 + } + DEF MU_select_pod_HL Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "EggGlow" + } + } + geometry DEF MU_select_pod_HL-FACES Mesh { url "MU_select_pod_HL-FACES.xm" } + } + ] + scale 0.465000 0.465000 0.465000 + translation 0.178000 -10.330000 3.150000 + } + ] + rotation 0.000000 1.000000 0.000000 -0.366500 + translation -39.970001 46.250000 -179.300003 +} + + + +DEF MU_text_sixitems Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MusicCountText Text { font "Body" translate false justify "middle"} + } + ] + //rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.5000 0.5000 0.0 + //scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -44.789999 58.0500 -180.100006 +} + +Transform +{ + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MusicType Text { font "Body" width -8 translate true } + } + ] + scale 1.35 1.35 0.0 + translation -5.25 -7 13.230000 + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 54.990000 -186.000000 +} + +Transform +{ + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Partsz" + } + } + geometry DEF TrackNum Text { font "Body" width -8 translate true } + } + ] + scale 1.35 1.35 0.0 + translation -5.25 -7 13.230000 + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 54.390000 -186.000000 +} + +Transform +{ + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Partsz" + } + } + geometry DEF TimeNum Text { font "Body" width -8 translate true } + } + ] + scale 1.35 1.35 0.0 + translation -5.25 -7 13.230000 + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 53.790000 -186.000000 +} diff --git a/xips-source/jellyfin/jellyfin/outline.xbx b/xips-source/jellyfin/jellyfin/outline.xbx new file mode 100644 index 0000000..e423c4b Binary files /dev/null and b/xips-source/jellyfin/jellyfin/outline.xbx differ diff --git a/xips-source/jellyfin/jellyfin/panel4.xbx b/xips-source/jellyfin/jellyfin/panel4.xbx new file mode 100644 index 0000000..1b5628d Binary files /dev/null and b/xips-source/jellyfin/jellyfin/panel4.xbx differ diff --git a/xips-source/jellyfin/jellyfin/steel.xbx b/xips-source/jellyfin/jellyfin/steel.xbx new file mode 100644 index 0000000..af964be Binary files /dev/null and b/xips-source/jellyfin/jellyfin/steel.xbx differ diff --git a/xips-source/mediaplayer/mediaplayer.xap b/xips-source/mediaplayer/mediaplayer.xap index 8cdeb24..d3c6e6e 100644 --- a/xips-source/mediaplayer/mediaplayer.xap +++ b/xips-source/mediaplayer/mediaplayer.xap @@ -22,7 +22,7 @@ var nCurMediaItem; var nMediaItemCount; var bInCategoryView; -var nCurCategory; // 0 = Movies, 1 = TV Shows, 2 = Playlists +var nCurCategory; // 0 = Movies, 1 = TV Shows, 2 = Playlists, 3 = Plex (launches standalone scene) var rgCategoryName; var nMediaListScroll; // scroll offset into the (huge) movie/show list var nMediaListVisible; // visible row count (8) @@ -51,6 +51,8 @@ function initialize() rgCategoryName[0] = "Movies"; rgCategoryName[1] = "TV Shows"; rgCategoryName[2] = "Playlists"; + rgCategoryName[3] = "Plex"; + rgCategoryName[4] = "Jellyfin"; bInPlaylistItemView = 0; nPlaylistCursorBackup = 0; @@ -69,7 +71,7 @@ function BindActiveList() { if (bInCategoryView) { - nMediaItemCount = 3; + nMediaItemCount = 5; } else if (nCurCategory == 0) { @@ -79,13 +81,12 @@ function BindActiveList() { nMediaItemCount = theMediaCollection.GetShowCount(); } - else if (bInPlaylistItemView) - { - nMediaItemCount = thePlaylistCollection.GetItemCount(); - } else { - nMediaItemCount = thePlaylistCollection.GetCount(); + if (bInPlaylistItemView) + nMediaItemCount = thePlaylistCollection.GetItemCount(); + else + nMediaItemCount = thePlaylistCollection.GetCount(); } } @@ -167,15 +168,18 @@ function UpdateMediaList() c.MediaHeaderText.text = "MEDIA / TV SHOWS"; c.MusicCountText.text = nMediaItemCount + " SHOWS"; } - else if (bInPlaylistItemView) - { - c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; - c.MusicCountText.text = nMediaItemCount + " ITEMS"; - } else { - c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; - c.MusicCountText.text = nMediaItemCount + " PLAYLISTS"; + if (bInPlaylistItemView) + { + c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; + c.MusicCountText.text = nMediaItemCount + " ITEMS"; + } + else + { + c.MediaHeaderText.text = "MEDIA / PLAYLISTS"; + c.MusicCountText.text = nMediaItemCount + " PLAYLISTS"; + } } } @@ -253,7 +257,17 @@ DEF theMediaMenu Level PlaySoundA(); if (bInCategoryView) { - // Drill into the selected category's item list. + if (nCurMediaItem == 3) + { + GoToPlex(); + return; + } + if (nCurMediaItem == 4) + { + GoToJellyfin(); + return; + } + nCurCategory = nCurMediaItem; bInCategoryView = 0; nCurMediaItem = 0; diff --git a/xips-source/plex/plex.xap b/xips-source/plex/plex.xap new file mode 100644 index 0000000..4d61250 --- /dev/null +++ b/xips-source/plex/plex.xap @@ -0,0 +1,450 @@ +// plex.xap: Plex Media Server browser scene (desktop only). +// nPlexView -- 0=libraries, 1=items, 2=seasons, 3=episodes. A descends, B +// ascends. Reads from CPlexLibrary's cache; sync on sign-in. + + +// ----- state ----- + +var nPlexView; // 0=libs, 1=items, 2=seasons, 3=episodes +var nPlexCursor; // selected row, absolute (0..count-1) +var nPlexScroll; // scroll offset into the list +var nPlexVisible; // visible row count (matches MenuButtons size) +var nPlexCount; // total rows in the current view +var nPlexLastCount; // last count we actually drew; behavior re-renders on mismatch +var nPlexCursorBackup0; // saved cursor when drilling lib -> items +var nPlexCursorBackup1; // saved cursor when drilling item -> seasons +var nPlexCursorBackup2; // saved cursor when drilling season -> episodes + + +function initialize() +{ + nPlexView = 0; + nPlexCursor = 0; + nPlexScroll = 0; + nPlexVisible = 8; + nPlexCount = 0; + nPlexLastCount = -1; + nPlexCursorBackup0 = 0; + nPlexCursorBackup1 = 0; + nPlexCursorBackup2 = 0; +} + + +// Recompute nPlexCount from the cache slice for the current view. +function BindActiveList() +{ + if (nPlexView == 0) + nPlexCount = thePlexLibrary.GetCount(); + else if (nPlexView == 1) + nPlexCount = thePlexLibrary.GetItemCount(); + else if (nPlexView == 2) + nPlexCount = thePlexLibrary.GetSeasonCount(); + else + nPlexCount = thePlexLibrary.GetEpisodeCount(); +} + + +function GetActiveTitle(absIdx) +{ + if (nPlexView == 0) + return thePlexLibrary.GetName(absIdx); + if (nPlexView == 1) + return thePlexLibrary.GetItemTitle(absIdx); + if (nPlexView == 2) + return thePlexLibrary.GetSeasonTitle(absIdx); + return thePlexLibrary.GetEpisodeTitle(absIdx); +} + + +// Breadcrumb header. Server -> library -> show -> season as user drills. +function GetHeaderText() +{ + var server = thePlexLibrary.GetServerName(); + if (server == "") + server = "PLEX"; + if (nPlexView == 0) + return server; + if (nPlexView == 1) + return server + " / " + thePlexLibrary.GetName(nPlexCursorBackup0); + var showTitle = thePlexLibrary.GetItemTitle(nPlexCursorBackup1); + if (nPlexView == 2) + return showTitle; + return showTitle + " / " + thePlexLibrary.GetSeasonTitle(nPlexCursorBackup2); +} + + +function GetCountText() +{ + if (nPlexView == 0) + { + if (nPlexCount == 1) return "1 LIBRARY"; + return nPlexCount + " LIBRARIES"; + } + if (nPlexView == 1) + { + if (nPlexCount == 1) return "1 ITEM"; + return nPlexCount + " ITEMS"; + } + if (nPlexView == 2) + { + if (nPlexCount == 1) return "1 SEASON"; + return nPlexCount + " SEASONS"; + } + if (nPlexCount == 1) return "1 EPISODE"; + return nPlexCount + " EPISODES"; +} + + +function StartPlexLevel() +{ + var c = thePlexMenu.children[0].children[0]; + // Hide CDs 2-5; only MU_cd_01 is repurposed as the poster surface. + c.MU_cd_02.visible = false; + c.MU_cd_03.visible = false; + c.MU_cd_04.visible = false; + c.MU_cd_05.visible = false; + + BindActiveList(); + UpdatePlexList(); +} + + +function UpdatePlexList() +{ + var c = thePlexMenu.children[0].children[0]; + + if (nPlexCursor < nPlexScroll) + nPlexScroll = nPlexCursor; + else if (nPlexCursor >= nPlexScroll + nPlexVisible) + nPlexScroll = nPlexCursor - (nPlexVisible - 1); + + for (var i = 0; i < nPlexVisible; i = i + 1) + { + var absIdx = nPlexScroll + i; + if (absIdx < nPlexCount) + { + c.MenuButtons.children[i].visible = true; + c.MenuButtons.children[i].children[1].children[0].geometry.text = GetActiveTitle(absIdx); + c.MenuButtons.children[i].children[0].children[0].appearance.material.name = "GameHilite"; + + if (absIdx == nPlexCursor) + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "GameHilite_01.bmp"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "HilightedType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0.1; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 1; + } + else + { + c.MenuButtons.children[i].children[0].children[0].appearance.texture.url = "outline.tga"; + c.MenuButtons.children[i].children[1].children[0].appearance.name = "NavType"; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollRate = 0; + c.MenuButtons.children[i].children[1].children[0].geometry.scrollDelay = 0; + } + } + else + { + c.MenuButtons.children[i].visible = false; + } + } + + c.MediaHeaderText.text = GetHeaderText(); + c.MusicCountText.text = GetCountText(); + + c.MusicType.text = ""; + c.TimeNum.text = ""; + c.TrackNum.text = ""; + + UpdatePoster(c); + + nPlexLastCount = nPlexCount; +} + + +// Retexture the orb's top CD (MU_cd_01) with the cursor item's poster. +function UpdatePoster(c) +{ + if (nPlexView == 1) + { + thePlexLibrary.QueueItemArt(nPlexCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = thePlexLibrary.GetItemArtPath(nPlexCursor); + } + else if (nPlexView == 2) + { + thePlexLibrary.QueueSeasonArt(nPlexCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = thePlexLibrary.GetSeasonArtPath(nPlexCursor); + } + else if (nPlexView == 3) + { + thePlexLibrary.QueueEpisodeArt(nPlexCursor); + c.MU_cd_01.children[0].children[0].appearance.texture.url = thePlexLibrary.GetEpisodeArtPath(nPlexCursor); + } + else + { + c.MU_cd_01.children[0].children[0].appearance.texture.url = "E:\\Plex\\plex_logo.png"; + } +} + + +function EnterLibrary() +{ + thePlexLibrary.SetCurrent(nPlexCursor); + nPlexCursorBackup0 = nPlexCursor; + nPlexView = 1; + nPlexCursor = 0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); +} + + +// Movies/tracks play immediately; TV shows drill into seasons. +function EnterItem() +{ + var type = thePlexLibrary.GetItemType(nPlexCursor); + if (type == "show") + { + thePlexLibrary.DrillIntoShow(nPlexCursor); + nPlexCursorBackup1 = nPlexCursor; + nPlexView = 2; + nPlexCursor = 0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else + { + thePlexLibrary.PlayItem(nPlexCursor); + } +} + + +function EnterSeason() +{ + thePlexLibrary.DrillIntoSeason(nPlexCursor); + nPlexCursorBackup2 = nPlexCursor; + nPlexView = 3; + nPlexCursor = 0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); +} + + +function PlayCurrentEpisode() +{ + thePlexLibrary.PlayEpisode(nPlexCursor); +} + + +// B: pop to parent view (restoring its cursor) or out to main menu. +function GoBackOne() +{ + if (nPlexView == 3) + { + nPlexView = 2; + nPlexCursor = nPlexCursorBackup2; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else if (nPlexView == 2) + { + nPlexView = 1; + nPlexCursor = nPlexCursorBackup1; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else if (nPlexView == 1) + { + nPlexView = 0; + nPlexCursor = nPlexCursorBackup0; + nPlexScroll = 0; + BindActiveList(); + UpdatePlexList(); + } + else + { + theMainMenu.GoBackTo(); + } +} + + +//////////////////////////////////////////////////////////////////////////// +// thePlexMenu Level. Uses plex.xip (clone of mediaplayer chrome). +//////////////////////////////////////////////////////////////////////////// + +DEF thePlexMenu Level +{ + archive "plex.xip" + children + [ + Inline + { + url "plex/default.xap" + + function onLoad() + { + StartPlexLevel(); + } + } + ] + + shell Transform + { + scale 21.504 21.504 21.504 + translation -44.599998 54.270000 -186.500000 + children + [ + DEF thePlexMenuWaver Waver + { + rpm 0.75 + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_01" } + texture ImageTexture + { + alpha true + url "cellwall.bmp" + } + } + geometry Mesh { url "Inner_cell-FACES.xm" } + } + Shape + { + appearance Appearance + { + material MaxMaterial { name "InnerWall_02" } + } + geometry Sphere { radius 1 } + } + ] + } + ] + } + + path DEF thePlexViewpoint Viewpoint + { + fieldOfView 1.287000 + orientation 0.015660 0.999900 0.001474 -0.187700 + position -47.840000 53.889999 -172.199997 + jump false + } + + control Joystick + { + function OnADown() + { + PlaySoundA(); + + if (thePlexLibrary.IsSyncReady() == 0) + { + return; + } + + if (nPlexCount <= 0) + return; + + if (nPlexView == 0) + EnterLibrary(); + else if (nPlexView == 1) + EnterItem(); + else if (nPlexView == 2) + EnterSeason(); + else + PlayCurrentEpisode(); + } + + function OnBDown() + { + PlaySoundB(); + GoBackOne(); + } + + function OnMoveUp() + { + BindActiveList(); + + if (nPlexCursor <= 0) + { + UpdatePlexList(); + return; + } + + PlaySoundMenuChange(); + nPlexCursor = nPlexCursor - 1; + UpdatePlexList(); + } + + function OnMoveDown() + { + BindActiveList(); + + if (nPlexCursor >= nPlexCount - 1) + { + UpdatePlexList(); + return; + } + + PlaySoundMenuChange(); + nPlexCursor = nPlexCursor + 1; + UpdatePlexList(); + } + + function OnXDown() { PlaySoundError(); } + function OnYDown() + { + // Y: modal info with full plot. No-op at library view. + if (nPlexView == 0) + { + PlaySoundError(); + return; + } + PlaySoundA(); + var info = thePlexLibrary.GetInfoText(nPlexView, nPlexCursor); + if (info == "") + info = "No additional information."; + TellUser(info, ""); + // Marquee MessageText if the plot overflows -- height must + // be non-zero for the scroll multiplier to kick in. + var mc = theMessageInline.children[0]; + mc.MessageText.height = 6.0; + mc.MessageText.scrollRate = 0.15; + mc.MessageText.scrollDelay = 2; + } + function OnWhiteDown(){ PlaySoundError(); } + function OnBlackDown(){ PlaySoundError(); } + } + + function OnArrival() + { + EnableInput(true); + } + + function OnActivate() + { + CurrentViewpoint = thePlexViewpoint; + } + + // Watchdog: async drill data can land after the scene is shown. + // Poll at 2.5Hz, redraw only on count change. + behavior + { + sleep .4; + BindActiveList(); + if (nPlexCount != nPlexLastCount) + { + UpdatePlexList(); + } + } +} + + +DEF thePlexLibrary PlexLibrary +{ +} diff --git a/xips-source/plex/plex/MU_GameModule_140-FACES.xm.meta b/xips-source/plex/plex/MU_GameModule_140-FACES.xm.meta new file mode 100644 index 0000000..9c7f7cf --- /dev/null +++ b/xips-source/plex/plex/MU_GameModule_140-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=229503 +primitive_count=2040 diff --git a/xips-source/plex/plex/MU_GameModule_142-FACES.xm.meta b/xips-source/plex/plex/MU_GameModule_142-FACES.xm.meta new file mode 100644 index 0000000..5aa30d3 --- /dev/null +++ b/xips-source/plex/plex/MU_GameModule_142-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=36372 +primitive_count=2040 diff --git a/xips-source/plex/plex/MU_GameModule_144-FACES.xm.meta b/xips-source/plex/plex/MU_GameModule_144-FACES.xm.meta new file mode 100644 index 0000000..0328d4c --- /dev/null +++ b/xips-source/plex/plex/MU_GameModule_144-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=117156 +primitive_count=2040 diff --git a/xips-source/plex/plex/MU_GameModule_158-FACES.xm.meta b/xips-source/plex/plex/MU_GameModule_158-FACES.xm.meta new file mode 100644 index 0000000..f8c38d3 --- /dev/null +++ b/xips-source/plex/plex/MU_GameModule_158-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=228135 +primitive_count=72 diff --git a/xips-source/plex/plex/MU_arm01_inner-FACES.xm.meta b/xips-source/plex/plex/MU_arm01_inner-FACES.xm.meta new file mode 100644 index 0000000..4e8f1ef --- /dev/null +++ b/xips-source/plex/plex/MU_arm01_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=81888 +primitive_count=2976 diff --git a/xips-source/plex/plex/MU_arm01_outer-FACES.xm.meta b/xips-source/plex/plex/MU_arm01_outer-FACES.xm.meta new file mode 100644 index 0000000..9a11dc9 --- /dev/null +++ b/xips-source/plex/plex/MU_arm01_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=228 +primitive_count=3768 diff --git a/xips-source/plex/plex/MU_arm02_inner-FACES.xm.meta b/xips-source/plex/plex/MU_arm02_inner-FACES.xm.meta new file mode 100644 index 0000000..f67c90e --- /dev/null +++ b/xips-source/plex/plex/MU_arm02_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=219207 +primitive_count=2976 diff --git a/xips-source/plex/plex/MU_arm02_outer-FACES.xm.meta b/xips-source/plex/plex/MU_arm02_outer-FACES.xm.meta new file mode 100644 index 0000000..4adc038 --- /dev/null +++ b/xips-source/plex/plex/MU_arm02_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=105852 +primitive_count=3768 diff --git a/xips-source/plex/plex/MU_back_pod_HL-FACES.xm.meta b/xips-source/plex/plex/MU_back_pod_HL-FACES.xm.meta new file mode 100644 index 0000000..49698e2 --- /dev/null +++ b/xips-source/plex/plex/MU_back_pod_HL-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=235623 +primitive_count=960 diff --git a/xips-source/plex/plex/MU_back_pod_inner-FACES.xm.meta b/xips-source/plex/plex/MU_back_pod_inner-FACES.xm.meta new file mode 100644 index 0000000..6a1c614 --- /dev/null +++ b/xips-source/plex/plex/MU_back_pod_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=25860 +primitive_count=960 diff --git a/xips-source/plex/plex/MU_back_pod_outer-FACES.xm.meta b/xips-source/plex/plex/MU_back_pod_outer-FACES.xm.meta new file mode 100644 index 0000000..da622da --- /dev/null +++ b/xips-source/plex/plex/MU_back_pod_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=90816 +primitive_count=960 diff --git a/xips-source/plex/plex/MU_back_probe-FACES.xm.meta b/xips-source/plex/plex/MU_back_probe-FACES.xm.meta new file mode 100644 index 0000000..7a665be --- /dev/null +++ b/xips-source/plex/plex/MU_back_probe-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=189399 +primitive_count=384 diff --git a/xips-source/plex/plex/MU_back_probearm-FACES.xm b/xips-source/plex/plex/MU_back_probearm-FACES.xm new file mode 100644 index 0000000..7435a23 Binary files /dev/null and b/xips-source/plex/plex/MU_back_probearm-FACES.xm differ diff --git a/xips-source/plex/plex/MU_back_probearm-FACES.xm.meta b/xips-source/plex/plex/MU_back_probearm-FACES.xm.meta new file mode 100644 index 0000000..037fb44 --- /dev/null +++ b/xips-source/plex/plex/MU_back_probearm-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=0 +primitive_count=76 diff --git a/xips-source/plex/plex/MU_back_tube-FACES.xm.meta b/xips-source/plex/plex/MU_back_tube-FACES.xm.meta new file mode 100644 index 0000000..a548988 --- /dev/null +++ b/xips-source/plex/plex/MU_back_tube-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=250083 +primitive_count=1196 diff --git a/xips-source/plex/plex/MU_backing_cd-FACES.xm.meta b/xips-source/plex/plex/MU_backing_cd-FACES.xm.meta new file mode 100644 index 0000000..6a17b83 --- /dev/null +++ b/xips-source/plex/plex/MU_backing_cd-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=120 +primitive_count=108 diff --git a/xips-source/plex/plex/MU_backing_cd_01-FACES.xm.meta b/xips-source/plex/plex/MU_backing_cd_01-FACES.xm.meta new file mode 100644 index 0000000..8cb67a0 --- /dev/null +++ b/xips-source/plex/plex/MU_backing_cd_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=444 +primitive_count=108 diff --git a/xips-source/plex/plex/MU_backing_cd_02-FACES.xm.meta b/xips-source/plex/plex/MU_backing_cd_02-FACES.xm.meta new file mode 100644 index 0000000..a7128ad --- /dev/null +++ b/xips-source/plex/plex/MU_backing_cd_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=2352 +primitive_count=108 diff --git a/xips-source/plex/plex/MU_backing_cd_03-FACES.xm.meta b/xips-source/plex/plex/MU_backing_cd_03-FACES.xm.meta new file mode 100644 index 0000000..21b3e66 --- /dev/null +++ b/xips-source/plex/plex/MU_backing_cd_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=2028 +primitive_count=108 diff --git a/xips-source/plex/plex/MU_backing_cd_04-FACES.xm.meta b/xips-source/plex/plex/MU_backing_cd_04-FACES.xm.meta new file mode 100644 index 0000000..f6881c7 --- /dev/null +++ b/xips-source/plex/plex/MU_backing_cd_04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1320 +primitive_count=108 diff --git a/xips-source/plex/plex/MU_backing_cd_05-FACES.xm.meta b/xips-source/plex/plex/MU_backing_cd_05-FACES.xm.meta new file mode 100644 index 0000000..1ac4f3f --- /dev/null +++ b/xips-source/plex/plex/MU_backing_cd_05-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1644 +primitive_count=108 diff --git a/xips-source/plex/plex/MU_cd_01-FACES.xm b/xips-source/plex/plex/MU_cd_01-FACES.xm new file mode 100644 index 0000000..63c9a10 Binary files /dev/null and b/xips-source/plex/plex/MU_cd_01-FACES.xm differ diff --git a/xips-source/plex/plex/MU_cd_01-FACES.xm.meta b/xips-source/plex/plex/MU_cd_01-FACES.xm.meta new file mode 100644 index 0000000..92a1c9a --- /dev/null +++ b/xips-source/plex/plex/MU_cd_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=2 +first_index=0 +primitive_count=6528 diff --git a/xips-source/plex/plex/MU_cd_02-FACES.xm.meta b/xips-source/plex/plex/MU_cd_02-FACES.xm.meta new file mode 100644 index 0000000..fa72124 --- /dev/null +++ b/xips-source/plex/plex/MU_cd_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=126396 +primitive_count=6528 diff --git a/xips-source/plex/plex/MU_cd_03-FACES.xm.meta b/xips-source/plex/plex/MU_cd_03-FACES.xm.meta new file mode 100644 index 0000000..0f83f1f --- /dev/null +++ b/xips-source/plex/plex/MU_cd_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=145980 +primitive_count=6528 diff --git a/xips-source/plex/plex/MU_cd_04-FACES.xm.meta b/xips-source/plex/plex/MU_cd_04-FACES.xm.meta new file mode 100644 index 0000000..689f112 --- /dev/null +++ b/xips-source/plex/plex/MU_cd_04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=62304 +primitive_count=6528 diff --git a/xips-source/plex/plex/MU_cd_05-FACES.xm.meta b/xips-source/plex/plex/MU_cd_05-FACES.xm.meta new file mode 100644 index 0000000..598214a --- /dev/null +++ b/xips-source/plex/plex/MU_cd_05-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=42720 +primitive_count=6528 diff --git a/xips-source/plex/plex/MU_menubutton_02a-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a-FACES.xm.meta new file mode 100644 index 0000000..29f1f0b --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=888 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02a01-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a01-FACES.xm.meta new file mode 100644 index 0000000..be447ef --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=948 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02a02-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a02-FACES.xm.meta new file mode 100644 index 0000000..ae8677f --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=60 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02a03-FACES.xm b/xips-source/plex/plex/MU_menubutton_02a03-FACES.xm new file mode 100644 index 0000000..5d95a38 Binary files /dev/null and b/xips-source/plex/plex/MU_menubutton_02a03-FACES.xm differ diff --git a/xips-source/plex/plex/MU_menubutton_02a03-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a03-FACES.xm.meta new file mode 100644 index 0000000..905f280 --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=0 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02a04-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a04-FACES.xm.meta new file mode 100644 index 0000000..1fe80ae --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=768 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02a05-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a05-FACES.xm.meta new file mode 100644 index 0000000..81f817f --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a05-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=828 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02a06-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02a06-FACES.xm.meta new file mode 100644 index 0000000..e8315b5 --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02a06-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1968 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_menubutton_02b-FACES.xm.meta b/xips-source/plex/plex/MU_menubutton_02b-FACES.xm.meta new file mode 100644 index 0000000..8ef28d0 --- /dev/null +++ b/xips-source/plex/plex/MU_menubutton_02b-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=273153 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_morearrow_down-FACES.xm.meta b/xips-source/plex/plex/MU_morearrow_down-FACES.xm.meta new file mode 100644 index 0000000..8c4bf76 --- /dev/null +++ b/xips-source/plex/plex/MU_morearrow_down-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=289839 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_morearrow_up-FACES.xm.meta b/xips-source/plex/plex/MU_morearrow_up-FACES.xm.meta new file mode 100644 index 0000000..049a159 --- /dev/null +++ b/xips-source/plex/plex/MU_morearrow_up-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=289899 +primitive_count=20 diff --git a/xips-source/plex/plex/MU_musicpod-FACES.xm.meta b/xips-source/plex/plex/MU_musicpod-FACES.xm.meta new file mode 100644 index 0000000..afde84e --- /dev/null +++ b/xips-source/plex/plex/MU_musicpod-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=93900 +primitive_count=3024 diff --git a/xips-source/plex/plex/MU_panel_backing-FACES.xm.meta b/xips-source/plex/plex/MU_panel_backing-FACES.xm.meta new file mode 100644 index 0000000..352dd38 --- /dev/null +++ b/xips-source/plex/plex/MU_panel_backing-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=1 +first_index=1008 +primitive_count=104 diff --git a/xips-source/plex/plex/MU_panel_meta-FACES.xm.meta b/xips-source/plex/plex/MU_panel_meta-FACES.xm.meta new file mode 100644 index 0000000..a888557 --- /dev/null +++ b/xips-source/plex/plex/MU_panel_meta-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=93696 +primitive_count=68 diff --git a/xips-source/plex/plex/MU_panel_support-FACES.xm.meta b/xips-source/plex/plex/MU_panel_support-FACES.xm.meta new file mode 100644 index 0000000..38b226c --- /dev/null +++ b/xips-source/plex/plex/MU_panel_support-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=32940 +primitive_count=84 diff --git a/xips-source/plex/plex/MU_panel_support_01-FACES.xm.meta b/xips-source/plex/plex/MU_panel_support_01-FACES.xm.meta new file mode 100644 index 0000000..6e24ecb --- /dev/null +++ b/xips-source/plex/plex/MU_panel_support_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=123276 +primitive_count=532 diff --git a/xips-source/plex/plex/MU_panel_support_02-FACES.xm.meta b/xips-source/plex/plex/MU_panel_support_02-FACES.xm.meta new file mode 100644 index 0000000..a1eb3de --- /dev/null +++ b/xips-source/plex/plex/MU_panel_support_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=289959 +primitive_count=36 diff --git a/xips-source/plex/plex/MU_pod_shell_01-FACES.xm.meta b/xips-source/plex/plex/MU_pod_shell_01-FACES.xm.meta new file mode 100644 index 0000000..5e15830 --- /dev/null +++ b/xips-source/plex/plex/MU_pod_shell_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=287541 +primitive_count=766 diff --git a/xips-source/plex/plex/MU_pod_shell_02-FACES.xm.meta b/xips-source/plex/plex/MU_pod_shell_02-FACES.xm.meta new file mode 100644 index 0000000..e53e6e0 --- /dev/null +++ b/xips-source/plex/plex/MU_pod_shell_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=125622 +primitive_count=258 diff --git a/xips-source/plex/plex/MU_pod_shell_03-FACES.xm.meta b/xips-source/plex/plex/MU_pod_shell_03-FACES.xm.meta new file mode 100644 index 0000000..9e64ebc --- /dev/null +++ b/xips-source/plex/plex/MU_pod_shell_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=124872 +primitive_count=250 diff --git a/xips-source/plex/plex/MU_pod_support_01-FACES.xm.meta b/xips-source/plex/plex/MU_pod_support_01-FACES.xm.meta new file mode 100644 index 0000000..1e90c4c --- /dev/null +++ b/xips-source/plex/plex/MU_pod_support_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=273213 +primitive_count=4776 diff --git a/xips-source/plex/plex/MU_pod_support_02-FACES.xm.meta b/xips-source/plex/plex/MU_pod_support_02-FACES.xm.meta new file mode 100644 index 0000000..80c99c6 --- /dev/null +++ b/xips-source/plex/plex/MU_pod_support_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=204879 +primitive_count=4776 diff --git a/xips-source/plex/plex/MU_pod_support_03-FACES.xm.meta b/xips-source/plex/plex/MU_pod_support_03-FACES.xm.meta new file mode 100644 index 0000000..ba06439 --- /dev/null +++ b/xips-source/plex/plex/MU_pod_support_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=190551 +primitive_count=4776 diff --git a/xips-source/plex/plex/MU_pod_support_04-FACES.xm.meta b/xips-source/plex/plex/MU_pod_support_04-FACES.xm.meta new file mode 100644 index 0000000..c62c3cd --- /dev/null +++ b/xips-source/plex/plex/MU_pod_support_04-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=11532 +primitive_count=4776 diff --git a/xips-source/plex/plex/MU_podsocket_inner-FACES.xm.meta b/xips-source/plex/plex/MU_podsocket_inner-FACES.xm.meta new file mode 100644 index 0000000..26d4af5 --- /dev/null +++ b/xips-source/plex/plex/MU_podsocket_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=35052 +primitive_count=440 diff --git a/xips-source/plex/plex/MU_podsocket_outer-FACES.xm.meta b/xips-source/plex/plex/MU_podsocket_outer-FACES.xm.meta new file mode 100644 index 0000000..0a5d3e1 --- /dev/null +++ b/xips-source/plex/plex/MU_podsocket_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=31620 +primitive_count=440 diff --git a/xips-source/plex/plex/MU_select_pod_HL-FACES.xm.meta b/xips-source/plex/plex/MU_select_pod_HL-FACES.xm.meta new file mode 100644 index 0000000..d976ac4 --- /dev/null +++ b/xips-source/plex/plex/MU_select_pod_HL-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=253671 +primitive_count=960 diff --git a/xips-source/plex/plex/MU_select_pod_inner-FACES.xm.meta b/xips-source/plex/plex/MU_select_pod_inner-FACES.xm.meta new file mode 100644 index 0000000..db477b3 --- /dev/null +++ b/xips-source/plex/plex/MU_select_pod_inner-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=28740 +primitive_count=960 diff --git a/xips-source/plex/plex/MU_select_pod_outer-FACES.xm.meta b/xips-source/plex/plex/MU_select_pod_outer-FACES.xm.meta new file mode 100644 index 0000000..988b0e3 --- /dev/null +++ b/xips-source/plex/plex/MU_select_pod_outer-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=102972 +primitive_count=960 diff --git a/xips-source/plex/plex/MU_select_probe-FACES.xm.meta b/xips-source/plex/plex/MU_select_probe-FACES.xm.meta new file mode 100644 index 0000000..c23358b --- /dev/null +++ b/xips-source/plex/plex/MU_select_probe-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=228351 +primitive_count=384 diff --git a/xips-source/plex/plex/MU_select_probearm-FACES.xm.meta b/xips-source/plex/plex/MU_select_probearm-FACES.xm.meta new file mode 100644 index 0000000..e096e5a --- /dev/null +++ b/xips-source/plex/plex/MU_select_probearm-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=42492 +primitive_count=76 diff --git a/xips-source/plex/plex/MU_select_tube-FACES.xm.meta b/xips-source/plex/plex/MU_select_tube-FACES.xm.meta new file mode 100644 index 0000000..d94b141 --- /dev/null +++ b/xips-source/plex/plex/MU_select_tube-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=246495 +primitive_count=1196 diff --git a/xips-source/plex/plex/MU_tube_01-FACES.xm.meta b/xips-source/plex/plex/MU_tube_01-FACES.xm.meta new file mode 100644 index 0000000..b539635 --- /dev/null +++ b/xips-source/plex/plex/MU_tube_01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=33192 +primitive_count=620 diff --git a/xips-source/plex/plex/MU_tube_02-FACES.xm.meta b/xips-source/plex/plex/MU_tube_02-FACES.xm.meta new file mode 100644 index 0000000..20cc342 --- /dev/null +++ b/xips-source/plex/plex/MU_tube_02-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=238503 +primitive_count=620 diff --git a/xips-source/plex/plex/MU_tube_03-FACES.xm.meta b/xips-source/plex/plex/MU_tube_03-FACES.xm.meta new file mode 100644 index 0000000..1398c05 --- /dev/null +++ b/xips-source/plex/plex/MU_tube_03-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=240363 +primitive_count=2044 diff --git a/xips-source/plex/plex/S_Vid__tv_tv01-FACES.xm.meta b/xips-source/plex/plex/S_Vid__tv_tv01-FACES.xm.meta new file mode 100644 index 0000000..724423e --- /dev/null +++ b/xips-source/plex/plex/S_Vid__tv_tv01-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=165564 +primitive_count=7945 diff --git a/xips-source/plex/plex/S_Vid__widescreen_tv-FACES.xm.meta b/xips-source/plex/plex/S_Vid__widescreen_tv-FACES.xm.meta new file mode 100644 index 0000000..72ff656 --- /dev/null +++ b/xips-source/plex/plex/S_Vid__widescreen_tv-FACES.xm.meta @@ -0,0 +1,4 @@ +type=mesh_reference +mesh_buffer=0 +first_index=256551 +primitive_count=5534 diff --git a/xips-source/plex/plex/cd_opacity.xbx b/xips-source/plex/plex/cd_opacity.xbx new file mode 100644 index 0000000..cad0643 Binary files /dev/null and b/xips-source/plex/plex/cd_opacity.xbx differ diff --git a/xips-source/plex/plex/default.xap b/xips-source/plex/plex/default.xap new file mode 100644 index 0000000..73634aa --- /dev/null +++ b/xips-source/plex/plex/default.xap @@ -0,0 +1,1979 @@ +DEF Music_Arm Transform +{ + children + [ + + DEF orb Transform + { + children + [ + DEF MU_backing_cd Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "DarkenBacking" + } + } + geometry DEF MU_backing_cd-FACES Mesh { url "MU_backing_cd-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.512600 0.512600 0.512600 + scaleOrientation 1.000000 0.000000 0.000000 0.000000 + translation -0.248800 -0.664600 -1.210000 + } + DEF MU_pod_support_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_03-FACES Mesh { url "MU_pod_support_03-FACES.xm" } + } + ] + rotation -0.923900 0.382700 0.000000 -3.142000 + scale 0.182900 0.182900 0.182900 + scaleOrientation 0.558100 -0.066720 0.827100 -0.205200 + translation -5.110000 1.530000 -2.960000 + } + DEF MU_arm01_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Cell_Light/LegSkin" + } + } + geometry DEF MU_arm01_outer-FACES Mesh { url "MU_arm01_outer-FACES.xm" } + } + ] + rotation -0.357400 0.862900 -0.357400 -1.718000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.479300 0.129100 0.868100 -0.737400 + translation 3.795000 2.995000 2.039000 + } + DEF MU_arm01_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_arm01_inner-FACES Mesh { url "MU_arm01_inner-FACES.xm" } + } + ] + rotation -0.357400 0.862900 -0.357400 -1.718000 + scale 0.404400 0.404400 0.404400 + scaleOrientation 0.999100 0.041310 0.000000 -0.170000 + translation 3.652000 3.138000 2.038000 + } + DEF MU_pod_shell_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_pod_shell_01-FACES Mesh { url "MU_pod_shell_01-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation -1.559000 -0.664600 0.863400 + } + DEF MU_pod_shell_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_pod_shell_02-FACES Mesh { url "MU_pod_shell_02-FACES.xm" } + } + ] + rotation -0.647700 0.538700 0.538700 -1.992000 + scale 0.465000 0.465000 0.465000 + translation -1.559000 -0.664600 0.863400 + } + DEF MU_pod_shell_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_pod_shell_03-FACES Mesh { url "MU_pod_shell_03-FACES.xm" } + } + ] + rotation -0.074120 0.705200 0.705200 -2.994000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.870700 -0.449300 0.199900 -0.271600 + translation -1.559000 -0.664600 0.863400 + } + DEF MU_pod_support_04 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_04-FACES Mesh { url "MU_pod_support_04-FACES.xm" } + } + ] + rotation 0.382700 -0.923900 0.000000 -3.142000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.700500 -0.712900 -0.033920 -0.080940 + translation 0.330300 2.914000 -2.960000 + } + /* DEF MU_musicpod Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GamePod" + } + } + geometry DEF MU_musicpod-FACES Mesh { url "MU_musicpod-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -1.571000 + scale 0.418900 0.418900 0.418900 + translation -1.727000 -0.832200 0.695800 + } + */ + DEF MU_pod_support_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_01-FACES Mesh { url "MU_pod_support_01-FACES.xm" } + } + ] + rotation 0.000000 0.000000 1.000000 -0.785400 + scale 0.182900 0.182900 0.182900 + scaleOrientation 1.000000 0.000000 0.000000 0.000000 + translation 0.330300 -3.911000 -2.960000 + } + DEF MU_podsocket_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_podsocket_inner-FACES Mesh { url "MU_podsocket_inner-FACES.xm" } + } + ] + rotation 0.862900 -0.357400 0.357400 -1.718000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.434200 -0.818000 -0.377200 -0.190100 + translation -1.796000 -0.640000 -3.240000 + } + DEF MU_pod_support_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_pod_support_02-FACES Mesh { url "MU_pod_support_02-FACES.xm" } + } + ] + rotation 0.000000 0.000000 1.000000 -2.356000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.107900 0.554400 -0.825200 -0.106600 + translation -5.110000 -2.527000 -2.960000 + } + DEF MU_podsocket_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_podsocket_outer-FACES Mesh { url "MU_podsocket_outer-FACES.xm" } + } + ] + rotation 0.862900 -0.357400 0.357400 -1.718000 + scale 0.182900 0.182900 0.182900 + scaleOrientation -0.434200 -0.818000 -0.377200 -0.190100 + translation -1.796000 -0.640000 -3.240000 + } + DEF MU_arm02_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_arm02_inner-FACES Mesh { url "MU_arm02_inner-FACES.xm" } + } + ] + rotation -0.663600 -0.345400 -0.663600 -3.807000 + scale 0.404400 0.404400 0.404400 + scaleOrientation 0.786300 -0.048550 -0.615900 -0.413500 + translation -4.386000 -3.954000 2.038000 + } + DEF MU_arm02_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Cell_Light/LegSkin" + } + } + geometry DEF MU_arm02_outer-FACES Mesh { url "MU_arm02_outer-FACES.xm" } + } + ] + rotation -0.663600 -0.345400 -0.663600 -3.807000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.129900 0.978100 -0.162800 -0.373900 + translation -4.552000 -3.837000 2.449000 + } + DEF MU_tube_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Tubes" + } + } + geometry DEF MU_tube_02-FACES Mesh { url "MU_tube_02-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 1.212000 5.402000 1.664000 + } + DEF MU_tube_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Tubes" + } + } + geometry DEF MU_tube_01-FACES Mesh { url "MU_tube_01-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 0.067240 5.402000 1.664000 + } + DEF MU_tube_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "Tubes" + } + } + geometry DEF MU_tube_03-FACES Mesh { url "MU_tube_03-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 3.058000 -1.425000 2.058000 + } + DEF MU_GameModule_158 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSrfc/PodParts" + } + } + geometry DEF MU_GameModule_158-FACES Mesh { url "MU_GameModule_158-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -1.571000 + scale 0.465000 0.465000 0.465000 + translation 3.721000 6.288000 2.808000 + } + DEF MU_tube_attachment_03 Transform + { + children + [ + DEF MU_GameModule_142 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_GameModule_142-FACES Mesh { url "MU_GameModule_142-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -0.130900 + translation -2.746000 -1.800000 22.650000 + } + ] + rotation -0.792900 0.608400 0.034620 -3.195000 + scale 0.174200 0.174200 0.174200 + scaleOrientation -0.039380 -0.053480 -0.997800 -0.152800 + translation 0.179000 -5.581000 4.294000 + } + DEF MU_tube_attachment_02 Transform + { + children + [ + DEF MU_GameModule_144 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_GameModule_144-FACES Mesh { url "MU_GameModule_144-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -0.130900 + translation -2.746000 -1.800000 22.650000 + } + ] + rotation -0.781900 -0.622000 0.041830 -3.117000 + scale 0.174200 0.174200 0.174200 + scaleOrientation 0.014660 0.521200 -0.853300 -0.277400 + translation -0.057290 4.140000 4.319000 + } + DEF MU_tube_attachment_01 Transform + { + children + [ + DEF MU_GameModule_140 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_GameModule_140-FACES Mesh { url "MU_GameModule_140-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -0.130900 + translation -2.746000 -1.800000 22.650000 + } + ] + rotation -0.736900 -0.675200 0.032170 -3.083000 + scale 0.174200 0.174200 0.174200 + scaleOrientation 0.141900 -0.517100 0.844100 -0.168000 + translation -1.287000 4.151000 4.322000 + } + ] + translation -41.630001 55.200001 -185.699997 + } + + Transform + { + children + [ + // This is the icon (a collapsed stack of CD's that can be expanded) + Transform + { + scale 2.5 2.5 2.5 + translation -1.68 -2 -2.5 + children + [ + Waver + { + rpm 10 + axis 0 1 0 + children + [ + /*DEF MU_backing_cd_05 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_05-FACES Mesh { url "MU_backing_cd_05-FACES.xm" } + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.438400 0.438400 0.438400 + scaleOrientation 0.694500 0.128700 0.707900 -1.001000 + translation 0.003821 -0.020290 -0.017460 + }*/ + DEF MU_cd_05 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.tga" + } + } + geometry DEF MU_cd_05-FACES Mesh { url "MU_cd_05-FACES.xm" } + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation -0.193000 0.354100 0.915100 -0.135400 + translation -0.015810 -0.030690 -0.039800 + } + + + DEF Group04 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_04 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + //alpha true + } + } + geometry DEF MU_backing_cd_04-FACES Mesh { url "MU_backing_cd_04-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.189999 -53.320000 183.300003 + }*/ + DEF MU_cd_04 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry DEF MU_cd_04-FACES Mesh { url "MU_cd_04-FACES.xm" } + } + ] + translation 43.119999 -53.389999 183.300003 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.430000 55.830002 -30.420000 + // translation 12.440000 56.130001 -30.120001 + } + + + DEF Group03 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_03-FACES Mesh { url "MU_backing_cd_03-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.180000 -52.259998 183.199997 + }*/ + DEF MU_cd_03 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry DEF MU_cd_03-FACES Mesh { url "MU_cd_03-FACES.xm" } + } + ] + translation 43.119999 -52.330002 183.199997 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.360000 55.500000 -30.600000 + // translation 12.390000 56.110001 -29.980000 + } + DEF Group02 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_02-FACES Mesh { url "MU_backing_cd_02-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.169998 -51.180000 182.800003 + }*/ + DEF MU_cd_02 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "ReflectSurface" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry DEF MU_cd_02-FACES Mesh { url "MU_cd_02-FACES.xm" } + } + ] + translation 43.110001 -51.259998 182.800003 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.310000 55.189999 -30.780001 + // translation 12.350000 56.110001 -29.830000 + } + + DEF Group01 Transform + { + fade 0.25 + children + [ + /*DEF MU_backing_cd_01 Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_01" + } + texture ImageTexture + { + url "cd_opacity.tga" + alpha true + } + } + geometry DEF MU_backing_cd_01-FACES Mesh { url "MU_backing_cd_01-FACES.xm" } + } + ] + rotation 1.000000 0.000000 0.000000 0.000000 + scale 1.323000 1.323000 1.323000 + scaleOrientation 0.745900 0.027230 0.665500 -0.294400 + translation 43.160000 -50.110001 182.500000 + }*/ + DEF MU_cd_01 Transform + { + children + [ + Transform + { + // Inherit Group01's chain for the base tilt; + // add a small X-axis rotation to lean the top + // back into the scene a touch. Negative Y on + // the scale keeps the texture right-side up. + rotation 1 0 -1 -0.4 + scale 10.0 -15.0 0.5 + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + texture ImageTexture + { + url "steel.bmp" + } + } + geometry Box + } + ] + } + ] + translation 43.090000 -50.180000 182.500000 + } + ] + rotation -0.872600 0.435400 0.221500 -0.891500 + scale 0.331300 0.331300 0.331300 + scaleOrientation 0.636300 -0.752300 0.170800 -0.080880 + translation 12.260000 54.869999 -30.950001 + // translation 12.310000 56.119999 -29.680000 + } + ] + } + ] + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 55.090000 -186.000000 + } + + DEF actual_orb Transform + { + children + [ + DEF MU_musicpod Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GamePod" + } + } + geometry DEF MU_musicpod-FACES Mesh { url "MU_musicpod-FACES.xm" } + } + ] + rotation 0.000000 1.000000 0.000000 -1.571000 + scale 0.418900 0.418900 0.418900 + translation -1.727000 -0.832200 0.695800 + } + ] + translation -41.630001 55.200001 -185.699997 + } + ] + translation 0.5 0.0 0.0 +} + + +DEF MU_panel_backing Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "PanelBacking_03" + } + texture ImageTexture + { + url "panel4.tga" + } + } + geometry DEF MU_panel_backing-FACES Mesh { url "MU_panel_backing-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -46.240002 55.180000 -180.500000 +} + + +DEF MU_panel_meta Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_meta-FACES Mesh { url "MU_panel_meta-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -47.090000 51.290001 -180.399994 +} + +DEF MU_panel_support Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_support-FACES Mesh { url "MU_panel_support-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.067560 0.683400 -0.726900 -0.002854 + translation -46.150002 57.490002 -180.399994 +} + +DEF MenuButtons Group +{ + children + [ + DEF MU_menubutton_02b Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02b-FACES Mesh { url "MU_menubutton_02a-FACES.xm" } + //{ url "MU_menubutton_02b-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.1 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -9 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.091940 0.559900 0.823500 -0.000926 + translation -47.209999 56.889999 -180.300003 + } + DEF MU_menubutton_01 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a-FACES Mesh { url "MU_menubutton_02a-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.1 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 56.209999 -180.300003 + } + DEF MU_menubutton_02 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a01-FACES Mesh { url "MU_menubutton_02a01-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.1 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 55.520000 -180.300003 + } + DEF MU_menubutton_03 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a02-FACES Mesh { url "MU_menubutton_02a02-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.14 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 54.840000 -180.300003 + } + DEF MU_menubutton_04 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a03-FACES Mesh { url "MU_menubutton_02a03-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.2 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 54.160000 -180.300003 + } + DEF MU_menubutton_05 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a04-FACES Mesh { url "MU_menubutton_02a04-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.18 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 53.480000 -180.300003 + } + DEF MU_menubutton_06 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a05-FACES Mesh { url "MU_menubutton_02a05-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.2 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -10 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 52.799999 -180.300003 + } + DEF MU_menubutton_07 Transform + { + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "GameHilite" + } + texture ImageTexture + { + url "outline.tga" + } + } + geometry DEF MU_menubutton_02a06-FACES Mesh { url "MU_menubutton_02a06-FACES.xm" } + } + ] + } + Transform + { + translation -7.5 -0.2 0.25 + rotation -1 0 0 1.571 + children + [ + Shape + { + appearance MaxMaterial { name "NavType" } + geometry Text { font "Body" translate false width -9 scrollRate 0 scrollDelay 0 scrollHorizontal true } + } + ] + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.117300 0.929800 0.348800 -0.002184 + translation -47.209999 52.119999 -180.300003 + } + ] +} + + +DEF MU_text_musicCollection Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MediaHeaderText Text { font "Heading" text "MEDIA" } + } + ] + //rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.7 0.7 0.0 + //scaleOrientation -0.040360 0.530400 0.846800 -0.004249 + translation -50.770000 57.649998 -180.199997 +} + +DEF MU_panel_support_01 Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_support_01-FACES Mesh { url "MU_panel_support_01-FACES.xm" } + } + ] + rotation -0.566400 0.566400 0.598600 -2.063000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.853300 0.476500 -0.211500 -0.295500 + translation -50.680000 53.520000 -180.199997 +} + +DEF MU_panel_support_02 Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_panel_support_02-FACES Mesh { url "MU_panel_support_02-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -47.090000 51.290001 -180.399994 +} + +DEF MU_morearrow_down Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MU_morearrow_down-FACES Mesh { url "MU_morearrow_down-FACES.xm" } + } + ] + rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -1.000000 0.006956 -0.003303 -0.292700 + translation -46.360001 52.200001 -180.199997 +} + +DEF MU_morearrow_up Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MU_morearrow_up-FACES Mesh { url "MU_morearrow_up-FACES.xm" } + } + ] + rotation 0.019530 -0.707000 0.707000 -3.181000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.954000 -0.028350 -0.298400 -0.143200 + translation -46.360001 56.840000 -180.199997 +} + +DEF Group06 Transform +{ + children + [ + DEF Group06-TIMER TimeSensor + { + cycleInterval 14.300000 + loop TRUE + } + DEF MU_back_probearm Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_back_probearm-FACES Mesh { url "MU_back_probearm-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 20.680000 14.960000 6.071000 + } + DEF MU_back_probe Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_back_probe-FACES Mesh { url "MU_back_probe-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 19.790001 14.110000 6.235000 + } + DEF MU_back_pod_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_back_pod_inner-FACES Mesh { url "MU_back_pod_inner-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -11.290000 3.150000 + } + DEF MU_Text_b Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry Text { font "heading" justify "middle" translate false text "B" } + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.605000 0.605000 0.000 + //scaleOrientation 0.000000 0.000000 1.000000 -0.005973 + translation 1.889000 2.422000 -0.409200 + } + DEF MU_Text_back Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "XBOXgreen" + } + } + geometry Text { font "Body" text "BACK" justify "end"} + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.375000 0.475000 0.00 + //scaleOrientation 0.000000 0.000000 1.000000 -0.005973 + translation 1.183300 2.156 -0.409200 + } + // Y cluster -- mirror of B's probearm + probe + pod + tube + + // label, shifted down by ~1.6 units so it forms a second row + // below B/BACK. Reuses the same mesh URLs since they're the + // same physical button parts. delta_y vs B = -1.622. + DEF MU_y_probearm Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "FlatSurfaces" } } + geometry Mesh { url "MU_back_probearm-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 20.680000 13.660000 6.071000 + } + DEF MU_y_probe Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "FlatSurfaces" } } + geometry Mesh { url "MU_back_probe-FACES.xm" } + } + ] + rotation 0.753300 0.386200 -0.532400 -1.747000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.981300 0.180300 0.067500 -0.715500 + translation 19.790001 12.810000 6.235000 + } + DEF MU_y_pod_inner Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "CellEgg/Parts" } } + geometry Mesh { url "MU_back_pod_inner-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -12.590000 3.150000 + } + DEF MU_y_pod_outer Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "CellEgg/Parts" } } + geometry Mesh { url "MU_back_pod_outer-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -12.590000 3.150000 + } + DEF MU_y_pod_HL Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "EggGlow" } } + geometry Mesh { url "MU_back_pod_HL-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -12.590000 3.150000 + } + DEF MU_y_tube Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "TubesFade" } } + geometry Mesh { url "MU_back_tube-FACES.xm" } + } + ] + rotation 0.990900 -0.095410 -0.095410 -1.580000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.010380 -0.285600 0.958300 -0.080610 + translation -22.030001 -29.050000 9.817000 + } + DEF MU_Text_y Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "NavType" } } + geometry Text { font "heading" justify "middle" translate false text "Y" } + } + ] + scale 0.605000 0.605000 0.000 + translation 1.889000 1.122000 -0.409200 + } + DEF MU_Text_info Transform + { + children + [ + Shape + { + appearance Appearance { material MaxMaterial { name "XBOXgreen" } } + geometry Text { font "Body" text "INFO" justify "end" } + } + ] + scale 0.375000 0.475000 0.00 + translation 1.183300 0.856000 -0.409200 + } + DEF MU_back_pod_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_back_pod_outer-FACES Mesh { url "MU_back_pod_outer-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -11.290000 3.150000 + } + DEF MU_back_tube Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "TubesFade" + } + } + geometry DEF MU_back_tube-FACES Mesh { url "MU_back_tube-FACES.xm" } + } + ] + rotation 0.990900 -0.095410 -0.095410 -1.580000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.010380 -0.285600 0.958300 -0.080610 + translation -22.030001 -27.750000 9.817000 + } + DEF MU_back_pod_HL Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "EggGlow" + } + } + geometry DEF MU_back_pod_HL-FACES Mesh { url "MU_back_pod_HL-FACES.xm" } + } + ] + rotation -1.000000 0.000000 0.000000 -3.142000 + scale 0.465000 0.465000 0.465000 + translation 11.010000 -11.290000 3.150000 + } + ] + rotation 0.000000 -1.000000 0.000000 -0.034910 + translation -52.740002 46.379999 -181.399994 +} +DEF Group07 Transform +{ + children + [ + DEF Group07-TIMER TimeSensor + { + cycleInterval 14.300000 + loop TRUE + } + DEF MU_Text_a Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry Text { font "heading" justify "middle" translate false text "A" } + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.605000 0.605000 0.000 + //scaleOrientation 0.000000 0.000000 -1.000000 -0.045050 + translation -1.959000 2.391000 -0.443200 + } + DEF MU_select_probearm Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_select_probearm-FACES Mesh { url "MU_select_probearm-FACES.xm" } + } + ] + rotation -0.786500 0.499900 0.362600 -1.910000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.000000 0.005536 1.000000 -0.474500 + translation -9.489000 15.920000 6.071000 + } + DEF MU_text_select Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "XBOXgreen" + } + } + geometry Text { font "Body" text "SELECT" } + } + ] + //rotation -1.000000 0.000000 0.000000 -1.571000 + scale 0.375000 0.475000 0.00 + //scaleOrientation 0.000000 0.000000 -1.000000 -0.097740 + translation -1.238900 2.156000 -0.409300 + } + DEF MU_select_probe Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "FlatSurfaces" + } + } + geometry DEF MU_select_probe-FACES Mesh { url "MU_select_probe-FACES.xm" } + } + ] + rotation -0.786500 0.499900 0.362600 -1.910000 + scale 0.465000 0.465000 0.465000 + scaleOrientation 0.000000 0.005536 1.000000 -0.474500 + translation -8.603000 15.060000 6.235000 + } + DEF MU_select_pod_inner Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_select_pod_inner-FACES Mesh { url "MU_select_pod_inner-FACES.xm" } + } + ] + scale 0.465000 0.465000 0.465000 + translation 0.178000 -10.330000 3.150000 + } + DEF MU_select_pod_outer Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Parts" + } + } + geometry DEF MU_select_pod_outer-FACES Mesh { url "MU_select_pod_outer-FACES.xm" } + } + ] + scale 0.465000 0.465000 0.465000 + translation 0.178000 -10.330000 3.150000 + } + DEF MU_select_tube Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "TubesFade" + } + } + geometry DEF MU_select_tube-FACES Mesh { url "MU_select_tube-FACES.xm" } + } + ] + rotation -0.990900 0.095410 -0.095410 -1.580000 + scale 0.465000 0.465000 0.465000 + scaleOrientation -0.011110 0.412400 0.911000 -0.088980 + translation 33.220001 -26.790001 9.817000 + } + DEF MU_select_pod_HL Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "EggGlow" + } + } + geometry DEF MU_select_pod_HL-FACES Mesh { url "MU_select_pod_HL-FACES.xm" } + } + ] + scale 0.465000 0.465000 0.465000 + translation 0.178000 -10.330000 3.150000 + } + ] + rotation 0.000000 1.000000 0.000000 -0.366500 + translation -39.970001 46.250000 -179.300003 +} + + + +DEF MU_text_sixitems Transform +{ + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MusicCountText Text { font "Body" translate false justify "middle"} + } + ] + //rotation -0.999200 -0.027600 0.027600 -1.572000 + scale 0.5000 0.5000 0.0 + //scaleOrientation -0.065000 0.552700 0.830800 -0.004331 + translation -44.789999 58.0500 -180.100006 +} + +Transform +{ + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "NavType" + } + } + geometry DEF MusicType Text { font "Body" width -8 translate true } + } + ] + scale 1.35 1.35 0.0 + translation -5.25 -7 13.230000 + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 54.990000 -186.000000 +} + +Transform +{ + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Partsz" + } + } + geometry DEF TrackNum Text { font "Body" width -8 translate true } + } + ] + scale 1.35 1.35 0.0 + translation -5.25 -7 13.230000 + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 54.390000 -186.000000 +} + +Transform +{ + children + [ + Transform + { + children + [ + Shape + { + appearance Appearance + { + material MaxMaterial + { + name "CellEgg/Partsz" + } + } + geometry DEF TimeNum Text { font "Body" width -8 translate true } + } + ] + scale 1.35 1.35 0.0 + translation -5.25 -7 13.230000 + } + ] + scale 0.465000 0.465000 0.465000 + translation -41.410000 53.790000 -186.000000 +} diff --git a/xips-source/plex/plex/outline.xbx b/xips-source/plex/plex/outline.xbx new file mode 100644 index 0000000..e423c4b Binary files /dev/null and b/xips-source/plex/plex/outline.xbx differ diff --git a/xips-source/plex/plex/panel4.xbx b/xips-source/plex/plex/panel4.xbx new file mode 100644 index 0000000..1b5628d Binary files /dev/null and b/xips-source/plex/plex/panel4.xbx differ diff --git a/xips-source/plex/plex/steel.xbx b/xips-source/plex/plex/steel.xbx new file mode 100644 index 0000000..af964be Binary files /dev/null and b/xips-source/plex/plex/steel.xbx differ