diff --git a/CMakeLists.txt b/CMakeLists.txt index a2e6863..e64436a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,7 @@ set(TETRIS_SOURCES src/audio/Audio.cpp src/gameplay/effects/LineEffect.cpp src/audio/SoundEffect.cpp + src/video/VideoPlayer.cpp src/ui/MenuLayout.cpp src/ui/BottomMenu.cpp src/app/BackgroundManager.cpp @@ -68,6 +69,7 @@ set(TETRIS_SOURCES src/states/LoadingManager.cpp # State implementations (new) src/states/LoadingState.cpp + src/states/VideoState.cpp src/states/MenuState.cpp src/states/OptionsState.cpp src/states/LevelSelectorState.cpp @@ -164,6 +166,13 @@ endif() target_link_libraries(spacetris PRIVATE SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image cpr::cpr nlohmann_json::nlohmann_json unofficial::enet::enet) +find_package(FFMPEG REQUIRED) +if(FFMPEG_FOUND) + target_include_directories(spacetris PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_directories(spacetris PRIVATE ${FFMPEG_LIBRARY_DIRS}) + target_link_libraries(spacetris PRIVATE ${FFMPEG_LIBRARIES}) +endif() + if (WIN32) target_link_libraries(spacetris PRIVATE mfplat mfreadwrite mfuuid ws2_32 winmm) endif() @@ -196,6 +205,7 @@ endif() target_include_directories(spacetris PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src/audio + ${CMAKE_SOURCE_DIR}/src/video ${CMAKE_SOURCE_DIR}/src/gameplay ${CMAKE_SOURCE_DIR}/src/graphics ${CMAKE_SOURCE_DIR}/src/persistence diff --git a/assets/videos/spacetris_intro.mp4 b/assets/videos/spacetris_intro.mp4 new file mode 100644 index 0000000..f53718f Binary files /dev/null and b/assets/videos/spacetris_intro.mp4 differ diff --git a/scripts/check_braces.ps1 b/scripts/check_braces.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_comments.ps1 b/scripts/check_comments.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/find_unmatched.ps1 b/scripts/find_unmatched.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/src/app/TetrisApp.cpp b/src/app/TetrisApp.cpp index 7a872b2..0bd4d0b 100644 --- a/src/app/TetrisApp.cpp +++ b/src/app/TetrisApp.cpp @@ -60,6 +60,7 @@ #include "states/MenuState.h" #include "states/OptionsState.h" #include "states/PlayingState.h" +#include "states/VideoState.h" #include "states/State.h" #include "ui/BottomMenu.h" @@ -310,11 +311,21 @@ struct TetrisApp::Impl { std::unique_ptr stateMgr; StateContext ctx{}; std::unique_ptr loadingState; + std::unique_ptr videoState; std::unique_ptr menuState; std::unique_ptr optionsState; std::unique_ptr levelSelectorState; std::unique_ptr playingState; + // Startup fade-in overlay (used after intro video). + bool startupFadeActive = false; + float startupFadeAlpha = 0.0f; // 0..1 black overlay strength + double startupFadeClockMs = 0.0; + static constexpr double STARTUP_FADE_IN_MS = 650.0; + + // Intro video path. + std::string introVideoPath = "assets/videos/spacetris_intro.mp4"; + int init(); void runLoop(); void shutdown(); @@ -671,7 +682,11 @@ int TetrisApp::Impl::init() }; ctx.requestFadeTransition = requestStateFade; + ctx.startupFadeActive = &startupFadeActive; + ctx.startupFadeAlpha = &startupFadeAlpha; + loadingState = std::make_unique(ctx); + videoState = std::make_unique(ctx); menuState = std::make_unique(ctx); optionsState = std::make_unique(ctx); levelSelectorState = std::make_unique(ctx); @@ -681,6 +696,20 @@ int TetrisApp::Impl::init() stateMgr->registerOnEnter(AppState::Loading, [this](){ loadingState->onEnter(); loadingStarted.store(true); }); stateMgr->registerOnExit(AppState::Loading, [this](){ loadingState->onExit(); }); + stateMgr->registerHandler(AppState::Video, [this](const SDL_Event& e){ if (videoState) videoState->handleEvent(e); }); + stateMgr->registerOnEnter(AppState::Video, [this]() { + if (!videoState) return; + const bool ok = videoState->begin(renderer, introVideoPath); + if (!ok) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Intro video unavailable; skipping to Menu"); + state = AppState::Menu; + stateMgr->setState(state); + return; + } + videoState->onEnter(); + }); + stateMgr->registerOnExit(AppState::Video, [this](){ if (videoState) videoState->onExit(); }); + stateMgr->registerHandler(AppState::Menu, [this](const SDL_Event& e){ menuState->handleEvent(e); }); stateMgr->registerOnEnter(AppState::Menu, [this](){ menuState->onEnter(); }); stateMgr->registerOnExit(AppState::Menu, [this](){ menuState->onExit(); }); @@ -832,7 +861,7 @@ void TetrisApp::Impl::runLoop() Settings::instance().setSoundEnabled(SoundEffectManager::instance().isEnabled()); } const bool helpToggleKey = - (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Menu); + (e.key.scancode == SDL_SCANCODE_F1 && state != AppState::Loading && state != AppState::Video && state != AppState::Menu); if (helpToggleKey) { showHelpOverlay = !showHelpOverlay; @@ -1168,6 +1197,21 @@ void TetrisApp::Impl::runLoop() if (frameMs > 100.0) frameMs = 100.0; gameplayBackgroundClockMs += frameMs; + if (startupFadeActive) { + if (startupFadeClockMs <= 0.0) { + startupFadeClockMs = STARTUP_FADE_IN_MS; + startupFadeAlpha = 1.0f; + } + startupFadeClockMs -= frameMs; + if (startupFadeClockMs <= 0.0) { + startupFadeClockMs = 0.0; + startupFadeAlpha = 0.0f; + startupFadeActive = false; + } else { + startupFadeAlpha = float(std::clamp(startupFadeClockMs / STARTUP_FADE_IN_MS, 0.0, 1.0)); + } + } + auto clearChallengeStory = [this]() { challengeStoryText.clear(); challengeStoryLevel = 0; @@ -1810,7 +1854,15 @@ void TetrisApp::Impl::runLoop() if (totalTasks > 0) { loadingProgress = std::min(1.0, double(doneTasks) / double(totalTasks)); if (loadingProgress >= 1.0 && musicLoaded) { - state = AppState::Menu; + startupFadeActive = false; + startupFadeAlpha = 0.0f; + startupFadeClockMs = 0.0; + + if (std::filesystem::exists(introVideoPath)) { + state = AppState::Video; + } else { + state = AppState::Menu; + } stateMgr->setState(state); } } else { @@ -1838,7 +1890,15 @@ void TetrisApp::Impl::runLoop() if (loadingProgress > 0.99) loadingProgress = 1.0; if (!musicLoaded && timeProgress >= 0.1) loadingProgress = 1.0; if (loadingProgress >= 1.0 && musicLoaded) { - state = AppState::Menu; + startupFadeActive = false; + startupFadeAlpha = 0.0f; + startupFadeClockMs = 0.0; + + if (std::filesystem::exists(introVideoPath)) { + state = AppState::Video; + } else { + state = AppState::Menu; + } stateMgr->setState(state); } } @@ -1905,6 +1965,9 @@ void TetrisApp::Impl::runLoop() case AppState::Loading: loadingState->update(frameMs); break; + case AppState::Video: + if (videoState) videoState->update(frameMs); + break; case AppState::Menu: menuState->update(frameMs); break; @@ -2207,6 +2270,11 @@ void TetrisApp::Impl::runLoop() } } break; + case AppState::Video: + if (videoState) { + videoState->render(renderer, logicalScale, logicalVP); + } + break; case AppState::Menu: if (!mainScreenTex) { mainScreenTex = assetLoader.getTexture(Assets::MAIN_SCREEN); @@ -2600,6 +2668,17 @@ void TetrisApp::Impl::runLoop() HelpOverlay::Render(renderer, pixelFont, LOGICAL_W, LOGICAL_H, contentOffsetX, contentOffsetY); } + if (startupFadeActive && startupFadeAlpha > 0.0f) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.0f, 1.0f); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + const Uint8 a = (Uint8)std::clamp((int)std::lround(startupFadeAlpha * 255.0f), 0, 255); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, a); + SDL_FRect full{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &full); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE); + } + SDL_RenderPresent(renderer); SDL_SetRenderScale(renderer, 1.f, 1.f); } diff --git a/src/core/application/ApplicationManager.cpp b/src/core/application/ApplicationManager.cpp index f919208..9e4da60 100644 --- a/src/core/application/ApplicationManager.cpp +++ b/src/core/application/ApplicationManager.cpp @@ -32,9 +32,19 @@ #include #include "../../utils/ImagePathResolver.h" #include +#include "../../video/VideoPlayer.h" #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif +// (Intro video playback is now handled in-process via VideoPlayer) ApplicationManager::ApplicationManager() = default; @@ -55,7 +65,15 @@ void ApplicationManager::renderLoading(ApplicationManager* app, RenderManager& r if (winW_actual > 0 && winH_actual > 0) app->m_starfield3D->resize(winW_actual, winH_actual); app->m_starfield3D->draw(renderer.getSDLRenderer()); } - + // If intro video is playing, render it instead of the loading UI + if (app->m_introStarted && app->m_videoPlayer) { + SDL_Renderer* sdlR = renderer.getSDLRenderer(); + int winW=0, winH=0; renderer.getWindowSize(winW, winH); + app->m_videoPlayer->render(sdlR, winW, winH); + SDL_SetRenderViewport(renderer.getSDLRenderer(), nullptr); + SDL_SetRenderScale(renderer.getSDLRenderer(), 1.0f, 1.0f); + return; + } SDL_Rect logicalVP = {0,0,0,0}; float logicalScale = 1.0f; if (app->m_renderManager) { @@ -780,17 +798,44 @@ void ApplicationManager::setupStateHandlers() { m_starfield3D->update(deltaTime / 1000.0f); } - // Check if loading is complete and transition to menu + // Check if loading is complete and transition to next stage if (m_assetManager->isLoadingComplete()) { - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, transitioning to Menu"); - + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Loading complete, handling post-load flow"); + // Update texture pointers now that assets are loaded m_stateContext.backgroundTex = m_assetManager->getTexture("background"); m_stateContext.blocksTex = m_assetManager->getTexture("blocks"); - - bool ok = m_stateManager->setState(AppState::Menu); - SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "setState(AppState::Menu) returned %d", ok ? 1 : 0); - traceFile("- to Menu returned"); + + // If an intro video exists and hasn't been started, attempt to play it in-process + std::filesystem::path introPath = m_introPath; + if (!m_introStarted && std::filesystem::exists(introPath)) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video found: %s", introPath.string().c_str()); + try { + if (!m_videoPlayer) m_videoPlayer = std::make_unique(); + SDL_Renderer* sdlRend = (m_renderManager) ? m_renderManager->getSDLRenderer() : nullptr; + if (m_videoPlayer->open(introPath.string(), sdlRend)) { + m_introStarted = true; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video started in-process"); + } else { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "VideoPlayer failed to open intro; skipping"); + m_stateManager->setState(AppState::Playing); + } + } catch (const std::exception& ex) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "Exception while starting VideoPlayer: %s", ex.what()); + m_stateManager->setState(AppState::Playing); + } + } else if (m_introStarted) { + // Let VideoPlayer decode frames; once finished, transition to playing + if (m_videoPlayer) m_videoPlayer->update(); + if (!m_videoPlayer || m_videoPlayer->isFinished()) { + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Intro video finished (in-process), transitioning to Playing"); + m_stateManager->setState(AppState::Playing); + } + } else { + // No intro to play; transition directly to Playing + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "No intro video; transitioning to Playing"); + m_stateManager->setState(AppState::Playing); + } } }); diff --git a/src/core/application/ApplicationManager.h b/src/core/application/ApplicationManager.h index ea6e43c..ee6002a 100644 --- a/src/core/application/ApplicationManager.h +++ b/src/core/application/ApplicationManager.h @@ -153,6 +153,11 @@ private: float m_logoAnimCounter = 0.0f; bool m_helpOverlayPausedGame = false; + // Intro video playback (in-process via FFmpeg) + bool m_introStarted = false; + std::string m_introPath = "assets/videos/spacetris_intro.mp4"; + std::unique_ptr m_videoPlayer; + // Gameplay background (per-level) with fade, mirroring main.cpp behavior SDL_Texture* m_levelBackgroundTex = nullptr; SDL_Texture* m_nextLevelBackgroundTex = nullptr; // used during fade transitions diff --git a/src/core/state/StateManager.cpp b/src/core/state/StateManager.cpp index 598e016..7d526a2 100644 --- a/src/core/state/StateManager.cpp +++ b/src/core/state/StateManager.cpp @@ -156,9 +156,19 @@ void StateManager::render(RenderManager& renderer) { } bool StateManager::isValidState(AppState state) const { - // All enum values are currently valid - return static_cast(state) >= static_cast(AppState::Loading) && - static_cast(state) <= static_cast(AppState::GameOver); + switch (state) { + case AppState::Loading: + case AppState::Video: + case AppState::Menu: + case AppState::Options: + case AppState::LevelSelector: + case AppState::Playing: + case AppState::LevelSelect: + case AppState::GameOver: + return true; + default: + return false; + } } bool StateManager::canTransitionTo(AppState newState) const { @@ -169,6 +179,7 @@ bool StateManager::canTransitionTo(AppState newState) const { const char* StateManager::getStateName(AppState state) const { switch (state) { case AppState::Loading: return "Loading"; + case AppState::Video: return "Video"; case AppState::Menu: return "Menu"; case AppState::Options: return "Options"; case AppState::LevelSelector: return "LevelSelector"; diff --git a/src/core/state/StateManager.h b/src/core/state/StateManager.h index 7805475..20379da 100644 --- a/src/core/state/StateManager.h +++ b/src/core/state/StateManager.h @@ -12,6 +12,7 @@ class RenderManager; // Application states used across the app enum class AppState { Loading, + Video, Menu, Options, LevelSelector, diff --git a/src/states/State.h b/src/states/State.h index 17af928..f8fc226 100644 --- a/src/states/State.h +++ b/src/states/State.h @@ -104,6 +104,11 @@ struct StateContext { std::function requestQuit; // Allows menu/option states to close the app gracefully std::function startPlayTransition; // Optional fade hook when transitioning from menu to gameplay std::function requestFadeTransition; // Generic state fade requests (menu/options/level) + + // Startup transition fade (used for intro video -> main). + // When active, the app should render a black overlay with alpha = startupFadeAlpha*255. + bool* startupFadeActive = nullptr; + float* startupFadeAlpha = nullptr; // Pointer to the application's StateManager so states can request transitions StateManager* stateManager = nullptr; // Optional explicit per-button coordinates (logical coordinates). When diff --git a/src/states/VideoState.cpp b/src/states/VideoState.cpp new file mode 100644 index 0000000..10360dc --- /dev/null +++ b/src/states/VideoState.cpp @@ -0,0 +1,389 @@ +// VideoState.cpp +#include "VideoState.h" + +#include "../video/VideoPlayer.h" +#include "../audio/Audio.h" +#include "../core/state/StateManager.h" + +#include + +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +VideoState::VideoState(StateContext& ctx) + : State(ctx) + , m_player(std::make_unique()) +{ +} + +VideoState::~VideoState() { + onExit(); +} + +bool VideoState::begin(SDL_Renderer* renderer, const std::string& path) { + m_path = path; + + if (!m_player) { + m_player = std::make_unique(); + } + + if (!m_player->open(m_path, renderer)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to open intro video: %s", m_path.c_str()); + return false; + } + + if (!m_player->decodeFirstFrame()) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "[VideoState] Failed to decode first frame: %s", m_path.c_str()); + // Still allow entering; we will likely render black. + } + + return true; +} + +void VideoState::onEnter() { + m_phase = Phase::FadeInFirstFrame; + m_phaseClockMs = 0.0; + m_blackOverlayAlpha = 1.0f; + + m_audioDecoded.store(false); + m_audioDecodeFailed.store(false); + m_audioStarted = false; + m_audioPcm.clear(); + m_audioRate = 44100; + m_audioChannels = 2; + + // Decode audio in the background during fade-in. + m_audioThread = std::make_unique([this](std::stop_token st) { + (void)st; + std::vector pcm; + int rate = 44100; + int channels = 2; + + const bool ok = decodeAudioPcm16Stereo44100(m_path, pcm, rate, channels); + if (!ok) { + m_audioDecodeFailed.store(true); + m_audioDecoded.store(true, std::memory_order_release); + return; + } + + // Transfer results. + m_audioRate = rate; + m_audioChannels = channels; + m_audioPcm = std::move(pcm); + m_audioDecoded.store(true, std::memory_order_release); + }); +} + +void VideoState::onExit() { + stopAudio(); + + if (m_audioThread) { + // Request stop and join. + m_audioThread.reset(); + } +} + +void VideoState::handleEvent(const SDL_Event& e) { + (void)e; +} + +void VideoState::startAudioIfReady() { + if (m_audioStarted) return; + if (!m_audioDecoded.load(std::memory_order_acquire)) return; + if (m_audioDecodeFailed.load()) return; + if (m_audioPcm.empty()) return; + + // Use the existing audio output path (same device as music/SFX). + Audio::instance().playSfx(m_audioPcm, m_audioChannels, m_audioRate, 1.0f); + m_audioStarted = true; +} + +void VideoState::stopAudio() { + // We currently feed intro audio as an SFX buffer into the mixer. + // It will naturally end; no explicit stop is required. +} + +void VideoState::update(double frameMs) { + switch (m_phase) { + case Phase::FadeInFirstFrame: { + m_phaseClockMs += frameMs; + const float t = (FADE_IN_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_IN_MS, 0.0, 1.0)) : 1.0f; + m_blackOverlayAlpha = 1.0f - t; + + if (t >= 1.0f) { + m_phase = Phase::Playing; + m_phaseClockMs = 0.0; + if (m_player) { + m_player->start(); + } + startAudioIfReady(); + } + break; + } + case Phase::Playing: { + startAudioIfReady(); + if (m_player) { + m_player->update(frameMs); + if (m_player->isFinished()) { + m_phase = Phase::FadeOutToBlack; + m_phaseClockMs = 0.0; + m_blackOverlayAlpha = 0.0f; + } + } else { + m_phase = Phase::FadeOutToBlack; + m_phaseClockMs = 0.0; + m_blackOverlayAlpha = 0.0f; + } + break; + } + case Phase::FadeOutToBlack: { + m_phaseClockMs += frameMs; + const float t = (FADE_OUT_MS > 0.0) ? float(std::clamp(m_phaseClockMs / FADE_OUT_MS, 0.0, 1.0)) : 1.0f; + m_blackOverlayAlpha = t; + if (t >= 1.0f) { + // Switch to MAIN (Menu) with a fade-in from black. + if (ctx.startupFadeAlpha) { + *ctx.startupFadeAlpha = 1.0f; + } + if (ctx.startupFadeActive) { + *ctx.startupFadeActive = true; + } + if (ctx.stateManager) { + ctx.stateManager->setState(AppState::Menu); + } + m_phase = Phase::Done; + } + break; + } + case Phase::Done: + default: + break; + } + } + + void VideoState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) { + (void)logicalScale; + (void)logicalVP; + + if (!renderer) return; + + int winW = 0, winH = 0; + SDL_GetRenderOutputSize(renderer, &winW, &winH); + + // Draw video fullscreen if available. + if (m_player && m_player->isTextureReady()) { + SDL_SetRenderViewport(renderer, nullptr); + SDL_SetRenderScale(renderer, 1.0f, 1.0f); + m_player->render(renderer, winW, winH); + } else { + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_FRect r{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &r); + } + + // Apply fade overlay (black). + if (m_blackOverlayAlpha > 0.0f) { + const Uint8 a = (Uint8)std::clamp((int)std::lround(m_blackOverlayAlpha * 255.0f), 0, 255); + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, 0, 0, 0, a); + SDL_FRect full{0.f, 0.f, (float)winW, (float)winH}; + SDL_RenderFillRect(renderer, &full); + } + } + +bool VideoState::decodeAudioPcm16Stereo44100( + const std::string& path, + std::vector& outPcm, + int& outRate, + int& outChannels +) { + outPcm.clear(); + outRate = 44100; + outChannels = 2; + + AVFormatContext* fmt = nullptr; + if (avformat_open_input(&fmt, path.c_str(), nullptr, nullptr) != 0) { + return false; + } + + if (avformat_find_stream_info(fmt, nullptr) < 0) { + avformat_close_input(&fmt); + return false; + } + + int audioStream = -1; + for (unsigned i = 0; i < fmt->nb_streams; ++i) { + if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) { + audioStream = (int)i; + break; + } + } + if (audioStream < 0) { + avformat_close_input(&fmt); + return false; + } + + AVCodecParameters* codecpar = fmt->streams[audioStream]->codecpar; + const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id); + if (!codec) { + avformat_close_input(&fmt); + return false; + } + + AVCodecContext* dec = avcodec_alloc_context3(codec); + if (!dec) { + avformat_close_input(&fmt); + return false; + } + + if (avcodec_parameters_to_context(dec, codecpar) < 0) { + avcodec_free_context(&dec); + avformat_close_input(&fmt); + return false; + } + + if (avcodec_open2(dec, codec, nullptr) < 0) { + avcodec_free_context(&dec); + avformat_close_input(&fmt); + return false; + } + + AVChannelLayout outLayout{}; + av_channel_layout_default(&outLayout, 2); + + AVChannelLayout inLayout{}; + if (av_channel_layout_copy(&inLayout, &dec->ch_layout) < 0 || inLayout.nb_channels <= 0) { + av_channel_layout_uninit(&inLayout); + av_channel_layout_default(&inLayout, 2); + } + + SwrContext* swr = nullptr; + if (swr_alloc_set_opts2( + &swr, + &outLayout, + AV_SAMPLE_FMT_S16, + 44100, + &inLayout, + dec->sample_fmt, + dec->sample_rate, + 0, + nullptr + ) < 0) { + av_channel_layout_uninit(&inLayout); + av_channel_layout_uninit(&outLayout); + avcodec_free_context(&dec); + avformat_close_input(&fmt); + return false; + } + + if (swr_init(swr) < 0) { + swr_free(&swr); + av_channel_layout_uninit(&inLayout); + av_channel_layout_uninit(&outLayout); + avcodec_free_context(&dec); + avformat_close_input(&fmt); + return false; + } + + AVPacket* pkt = av_packet_alloc(); + AVFrame* frame = av_frame_alloc(); + if (!pkt || !frame) { + if (pkt) av_packet_free(&pkt); + if (frame) av_frame_free(&frame); + swr_free(&swr); + av_channel_layout_uninit(&inLayout); + av_channel_layout_uninit(&outLayout); + avcodec_free_context(&dec); + avformat_close_input(&fmt); + return false; + } + + const int outRateConst = 44100; + const int outCh = 2; + + while (av_read_frame(fmt, pkt) >= 0) { + if (pkt->stream_index != audioStream) { + av_packet_unref(pkt); + continue; + } + + if (avcodec_send_packet(dec, pkt) < 0) { + av_packet_unref(pkt); + continue; + } + av_packet_unref(pkt); + + while (true) { + const int rr = avcodec_receive_frame(dec, frame); + if (rr == AVERROR(EAGAIN) || rr == AVERROR_EOF) { + break; + } + if (rr < 0) { + break; + } + + const int64_t delay = swr_get_delay(swr, dec->sample_rate); + const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP); + + std::vector outBytes; + outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t)); + + uint8_t* outData[1] = { outBytes.data() }; + const uint8_t** inData = (const uint8_t**)frame->data; + + const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples); + if (converted > 0) { + const size_t samplesOut = (size_t)converted * (size_t)outCh; + const int16_t* asS16 = (const int16_t*)outBytes.data(); + const size_t oldSize = outPcm.size(); + outPcm.resize(oldSize + samplesOut); + std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t)); + } + + av_frame_unref(frame); + } + } + + // Flush decoder + avcodec_send_packet(dec, nullptr); + while (avcodec_receive_frame(dec, frame) >= 0) { + const int64_t delay = swr_get_delay(swr, dec->sample_rate); + const int dstNbSamples = (int)av_rescale_rnd(delay + frame->nb_samples, outRateConst, dec->sample_rate, AV_ROUND_UP); + std::vector outBytes; + outBytes.resize((size_t)dstNbSamples * (size_t)outCh * sizeof(int16_t)); + uint8_t* outData[1] = { outBytes.data() }; + const uint8_t** inData = (const uint8_t**)frame->data; + const int converted = swr_convert(swr, outData, dstNbSamples, inData, frame->nb_samples); + if (converted > 0) { + const size_t samplesOut = (size_t)converted * (size_t)outCh; + const int16_t* asS16 = (const int16_t*)outBytes.data(); + const size_t oldSize = outPcm.size(); + outPcm.resize(oldSize + samplesOut); + std::memcpy(outPcm.data() + oldSize, asS16, samplesOut * sizeof(int16_t)); + } + av_frame_unref(frame); + } + + av_frame_free(&frame); + av_packet_free(&pkt); + swr_free(&swr); + av_channel_layout_uninit(&inLayout); + av_channel_layout_uninit(&outLayout); + avcodec_free_context(&dec); + avformat_close_input(&fmt); + + outRate = outRateConst; + outChannels = outCh; + + return !outPcm.empty(); +} diff --git a/src/states/VideoState.h b/src/states/VideoState.h new file mode 100644 index 0000000..f6a4642 --- /dev/null +++ b/src/states/VideoState.h @@ -0,0 +1,67 @@ +// VideoState.h +#pragma once + +#include "State.h" + +#include +#include +#include +#include +#include + +class VideoPlayer; + +class VideoState : public State { +public: + explicit VideoState(StateContext& ctx); + ~VideoState() override; + + void onEnter() override; + void onExit() override; + void handleEvent(const SDL_Event& e) override; + void update(double frameMs) override; + void render(SDL_Renderer* renderer, float logicalScale, SDL_Rect logicalVP) override; + + // Called from the App's on-enter hook so we can create textures. + bool begin(SDL_Renderer* renderer, const std::string& path); + +private: + enum class Phase { + FadeInFirstFrame, + Playing, + FadeOutToBlack, + Done + }; + + void startAudioIfReady(); + void stopAudio(); + + static bool decodeAudioPcm16Stereo44100( + const std::string& path, + std::vector& outPcm, + int& outRate, + int& outChannels + ); + + std::unique_ptr m_player; + std::string m_path; + + Phase m_phase = Phase::FadeInFirstFrame; + double m_phaseClockMs = 0.0; + + static constexpr double FADE_IN_MS = 900.0; + static constexpr double FADE_OUT_MS = 450.0; + + // Audio decoding runs in the background while we fade in. + std::atomic m_audioDecoded{false}; + std::atomic m_audioDecodeFailed{false}; + std::vector m_audioPcm; + int m_audioRate = 44100; + int m_audioChannels = 2; + bool m_audioStarted = false; + + std::unique_ptr m_audioThread; + + // Render-time overlay alpha (0..1) for fade stages. + float m_blackOverlayAlpha = 1.0f; +}; diff --git a/src/video/VideoPlayer.cpp b/src/video/VideoPlayer.cpp new file mode 100644 index 0000000..d97d587 --- /dev/null +++ b/src/video/VideoPlayer.cpp @@ -0,0 +1,172 @@ +#include "VideoPlayer.h" + +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +VideoPlayer::VideoPlayer() {} + +VideoPlayer::~VideoPlayer() { + if (m_texture) SDL_DestroyTexture(m_texture); + if (m_rgbBuffer) av_free(m_rgbBuffer); + if (m_frame) av_frame_free(&m_frame); + if (m_sws) sws_freeContext(m_sws); + if (m_dec) avcodec_free_context(&m_dec); + if (m_fmt) avformat_close_input(&m_fmt); +} + +bool VideoPlayer::open(const std::string& path, SDL_Renderer* renderer) { + m_path = path; + avformat_network_init(); + if (avformat_open_input(&m_fmt, path.c_str(), nullptr, nullptr) != 0) { + std::cerr << "VideoPlayer: failed to open " << path << "\n"; + return false; + } + if (avformat_find_stream_info(m_fmt, nullptr) < 0) { + std::cerr << "VideoPlayer: failed to find stream info\n"; + return false; + } + // Find video stream + m_videoStream = -1; + for (unsigned i = 0; i < m_fmt->nb_streams; ++i) { + if (m_fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { m_videoStream = (int)i; break; } + } + if (m_videoStream < 0) { std::cerr << "VideoPlayer: no video stream\n"; return false; } + + AVCodecParameters* codecpar = m_fmt->streams[m_videoStream]->codecpar; + const AVCodec* codec = avcodec_find_decoder(codecpar->codec_id); + if (!codec) { std::cerr << "VideoPlayer: decoder not found\n"; return false; } + m_dec = avcodec_alloc_context3(codec); + if (!m_dec) { std::cerr << "VideoPlayer: failed to alloc codec ctx\n"; return false; } + if (avcodec_parameters_to_context(m_dec, codecpar) < 0) { std::cerr << "VideoPlayer: param to ctx failed\n"; return false; } + if (avcodec_open2(m_dec, codec, nullptr) < 0) { std::cerr << "VideoPlayer: open codec failed\n"; return false; } + + m_width = m_dec->width; + m_height = m_dec->height; + m_frame = av_frame_alloc(); + m_sws = sws_getContext(m_width, m_height, m_dec->pix_fmt, m_width, m_height, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr); + m_rgbBufferSize = av_image_get_buffer_size(AV_PIX_FMT_RGBA, m_width, m_height, 1); + m_rgbBuffer = (uint8_t*)av_malloc(m_rgbBufferSize); + + if (renderer) { + m_texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32, SDL_TEXTUREACCESS_STREAMING, m_width, m_height); + if (!m_texture) { std::cerr << "VideoPlayer: failed create texture\n"; } + } + + m_finished = false; + m_textureReady = false; + m_started = false; + m_frameAccumulatorMs = 0.0; + + // Estimate frame interval. + m_frameIntervalMs = 33.333; + if (m_fmt && m_videoStream >= 0) { + AVRational fr = m_fmt->streams[m_videoStream]->avg_frame_rate; + if (fr.num > 0 && fr.den > 0) { + const double fps = av_q2d(fr); + if (fps > 1.0) { + m_frameIntervalMs = 1000.0 / fps; + } + } + } + + // Seek to start + av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD); + if (m_dec) avcodec_flush_buffers(m_dec); + return true; +} + +bool VideoPlayer::decodeOneFrame() { + if (m_finished || !m_fmt) return false; + + AVPacket* pkt = av_packet_alloc(); + if (!pkt) { + m_finished = true; + return false; + } + + int ret = 0; + while (av_read_frame(m_fmt, pkt) >= 0) { + if (pkt->stream_index == m_videoStream) { + ret = avcodec_send_packet(m_dec, pkt); + if (ret < 0) { + av_packet_unref(pkt); + continue; + } + + while (ret >= 0) { + ret = avcodec_receive_frame(m_dec, m_frame); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; + if (ret < 0) break; + + uint8_t* dstData[4] = { m_rgbBuffer, nullptr, nullptr, nullptr }; + int dstLinesize[4] = { m_width * 4, 0, 0, 0 }; + sws_scale(m_sws, m_frame->data, m_frame->linesize, 0, m_height, dstData, dstLinesize); + m_textureReady = true; + if (m_texture) { + SDL_UpdateTexture(m_texture, nullptr, m_rgbBuffer, dstLinesize[0]); + } + av_frame_unref(m_frame); + + av_packet_unref(pkt); + av_packet_free(&pkt); + return true; + } + } + av_packet_unref(pkt); + } + + av_packet_free(&pkt); + m_finished = true; + return false; +} + +bool VideoPlayer::decodeFirstFrame() { + if (!m_fmt || m_finished) return false; + if (m_textureReady) return true; + // Ensure we are at the beginning. + av_seek_frame(m_fmt, m_videoStream, 0, AVSEEK_FLAG_BACKWARD); + if (m_dec) avcodec_flush_buffers(m_dec); + return decodeOneFrame(); +} + +void VideoPlayer::start() { + m_started = true; +} + +bool VideoPlayer::update(double deltaMs) { + if (m_finished || !m_fmt) return false; + if (!m_started) return true; + + m_frameAccumulatorMs += deltaMs; + + // Decode at most a small burst per frame to avoid spiral-of-death. + int framesDecoded = 0; + const int maxFramesPerTick = 4; + while (m_frameAccumulatorMs >= m_frameIntervalMs && framesDecoded < maxFramesPerTick) { + m_frameAccumulatorMs -= m_frameIntervalMs; + if (!decodeOneFrame()) { + return false; + } + ++framesDecoded; + } + return !m_finished; +} + +bool VideoPlayer::update() { + // Legacy behavior: decode exactly one frame. + return decodeOneFrame(); +} + +void VideoPlayer::render(SDL_Renderer* renderer, int winW, int winH) { + if (!m_textureReady || !m_texture || !renderer) return; + if (winW <= 0 || winH <= 0) return; + SDL_FRect dst = { 0.0f, 0.0f, (float)winW, (float)winH }; + SDL_RenderTexture(renderer, m_texture, nullptr, &dst); +} diff --git a/src/video/VideoPlayer.h b/src/video/VideoPlayer.h new file mode 100644 index 0000000..1be8e27 --- /dev/null +++ b/src/video/VideoPlayer.h @@ -0,0 +1,59 @@ +// Minimal FFmpeg-based video player (video) that decodes into an SDL texture. +// Audio for the intro is currently handled outside this class. +#pragma once + +#include +#include + +struct AVFormatContext; +struct AVCodecContext; +struct SwsContext; +struct AVFrame; + +class VideoPlayer { +public: + VideoPlayer(); + ~VideoPlayer(); + + // Open video file and attach to SDL_Renderer for texture creation + bool open(const std::string& path, SDL_Renderer* renderer); + // Decode the first frame immediately so it can be used for fade-in. + bool decodeFirstFrame(); + + // Start time-based playback. + void start(); + + // Update playback using elapsed time in milliseconds. + // Returns false if finished or error. + bool update(double deltaMs); + + // Compatibility: advance by one decoded frame. + bool update(); + + // Render video frame fullscreen to the given renderer using provided output size. + void render(SDL_Renderer* renderer, int winW, int winH); + bool isFinished() const { return m_finished; } + bool isTextureReady() const { return m_textureReady; } + + double getFrameIntervalMs() const { return m_frameIntervalMs; } + bool isStarted() const { return m_started; } + +private: + bool decodeOneFrame(); + + AVFormatContext* m_fmt = nullptr; + AVCodecContext* m_dec = nullptr; + SwsContext* m_sws = nullptr; + AVFrame* m_frame = nullptr; + int m_videoStream = -1; + double m_frameIntervalMs = 33.333; + double m_frameAccumulatorMs = 0.0; + bool m_started = false; + int m_width = 0, m_height = 0; + SDL_Texture* m_texture = nullptr; + uint8_t* m_rgbBuffer = nullptr; + int m_rgbBufferSize = 0; + bool m_textureReady = false; + bool m_finished = true; + std::string m_path; +}; diff --git a/vcpkg.json b/vcpkg.json index 4ed94ac..f93b924 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -9,6 +9,7 @@ "enet", "catch2", "cpr", - "nlohmann-json" + "nlohmann-json", + "ffmpeg" ] } \ No newline at end of file