From e47299f90a94077cce70670088cf0fe22341224b Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 5 Jul 2026 23:20:35 +0200 Subject: [PATCH] feat(export): role+name nametag titles and combined keyvisual+nametag sermon intro --- app/Services/PlaylistExportService.php | 87 +++++++++++----- tests/Feature/FullPlaylistExportTest.php | 13 +-- tests/Feature/PlaylistSequenceTest.php | 124 ++++++++++++++++++++--- 3 files changed, 179 insertions(+), 45 deletions(-) diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php index 5078ef2..7f81b32 100644 --- a/app/Services/PlaylistExportService.php +++ b/app/Services/PlaylistExportService.php @@ -74,8 +74,10 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda foreach ($agendaItems as $item) { if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) { + $moderatorName = trim((string) ($moderatorSlideData['text'] ?? '')); + $moderatorPresentationName = $moderatorName !== '' ? 'Moderator - '.$moderatorName : 'Moderator'; $this->writeProAndEmbed( - 'Moderator', + $moderatorPresentationName, $moderatorSlideData, $tempDir, $playlistItems, @@ -136,12 +138,30 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda continue; } - if ($item->slides->isNotEmpty()) { - if ($this->backgroundPartTypeForAgendaItem($item) === 'sermon') { - $this->addKeyVisualSlide($service, $tempDir, $playlistItems, $embeddedFiles, 'Keyvisual-Predigt'); - $this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles); - } + $isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon'; + if ($isSermon) { + $this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles); + + if ($item->slides->isNotEmpty()) { + $countBefore = count($playlistItems); + $label = $item->title ?: 'Folien'; + $this->addSlidesFromCollection( + $item->slides, + 'agenda_'.$item->id, + $label, + $tempDir, + $playlistItems, + $embeddedFiles, + $service, + $this->backgroundPartTypeForAgendaItem($item), + ); + + if (count($playlistItems) > $countBefore) { + $realContentEmitted = true; + } + } + } elseif ($item->slides->isNotEmpty()) { $countBefore = count($playlistItems); $label = $item->title ?: 'Folien'; $this->addSlidesFromCollection( @@ -158,11 +178,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda if (count($playlistItems) > $countBefore) { $realContentEmitted = true; } - - continue; - } - - if (! $this->isNameTagAgendaItem($item)) { + } elseif (! $this->isNameTagAgendaItem($item)) { if ($realContentEmitted && ! $keyvisualFallbackEmitted) { $this->addKeyVisualFallbackPresentation( $item, @@ -719,27 +735,50 @@ private function buildPreacherSlideData(Service $service): ?array return app(NameTagSlideBuilder::class)->buildPreacherSlide($name); } - private function addKeyVisualSlide(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles, string $label = 'Keyvisual'): void + /** + * Emit the sermon intro as ONE presentation with up to two cues: + * slide 1 = key-visual (image only) + * slide 2 = key-visual WITH the preacher name tag rendered on top + * + * The presentation is named after the preacher ("Prediger - ") when a + * name tag is configured, otherwise "Keyvisual-Predigt". Emits nothing when + * neither a key-visual nor a name tag is available. + */ + private function addSermonIntroPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void { $kvData = $this->keyVisualData($service); - if ($kvData === null) { + $nameTagData = $this->buildPreacherSlideData($service); + + if ($kvData === null && $nameTagData === null) { return; } - $this->embedKeyVisual($service, $embeddedFiles); + $slides = []; - $slideData = ['imageOnly' => true, 'background' => $kvData]; - $this->writeProAndEmbed($label, $slideData, $tempDir, $playlistItems, $embeddedFiles); - } - - private function addPreacherNameTag(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void - { - $slideData = $this->buildPreacherSlideData($service); - if ($slideData === null) { - return; + if ($kvData !== null) { + $this->embedKeyVisual($service, $embeddedFiles); + $slides[] = ['imageOnly' => true, 'background' => $kvData]; } - $this->writeProAndEmbed('Predigername', $slideData, $tempDir, $playlistItems, $embeddedFiles); + if ($nameTagData !== null) { + if ($kvData !== null) { + $nameTagData['background'] = $kvData; + } + $slides[] = $nameTagData; + + $preacher = trim((string) ($nameTagData['text'] ?? '')); + $name = $preacher !== '' ? 'Prediger - '.$preacher : 'Prediger'; + } else { + $name = 'Keyvisual-Predigt'; + } + + $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]]; + $arrangements = [['name' => 'normal', 'groupNames' => [$name]]]; + $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro'; + $path = $tempDir.'/'.$filename; + $this->writeProFile($path, $name, $groups, $arrangements); + $embeddedFiles[$filename] = file_get_contents($path); + $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename]; } private function writeProAndEmbed(string $name, array $slideData, string $tempDir, array &$playlistItems, array &$embeddedFiles): void diff --git a/tests/Feature/FullPlaylistExportTest.php b/tests/Feature/FullPlaylistExportTest.php index 639b190..7f5e742 100644 --- a/tests/Feature/FullPlaylistExportTest.php +++ b/tests/Feature/FullPlaylistExportTest.php @@ -94,23 +94,20 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde $playlist = ProPlaylistReader::read($result['path']); $names = array_map(fn ($entry) => $entry->getName(), $playlist->getEntries()); - $moderatorIndex = array_search('Moderator', $names, true); + $moderatorIndex = array_search('Moderator - Moderator Max', $names, true); $songIndex = array_search('Großer Gott', $names, true); - $kvIndex = array_search('Keyvisual-Predigt', $names, true); - $preacherIndex = array_search('Predigername', $names, true); + $introIndex = array_search('Prediger - Pastor Paul', $names, true); $sermonIndex = array_search('Predigt', $names, true); $this->assertNotFalse($moderatorIndex, 'Moderator nametag missing'); $this->assertNotFalse($songIndex, 'Song presentation missing'); - $this->assertNotFalse($kvIndex, 'Keyvisual-Predigt entry missing'); - $this->assertNotFalse($preacherIndex, 'Predigername (preacher nametag) missing'); + $this->assertNotFalse($introIndex, 'Prediger intro (keyvisual + nametag) missing'); $this->assertNotFalse($sermonIndex, 'Predigt (sermon slides) missing'); $this->assertSame(0, $moderatorIndex, 'Moderator nametag must be first'); $this->assertLessThan($songIndex, $moderatorIndex); - $this->assertLessThan($kvIndex, $songIndex); - $this->assertLessThan($preacherIndex, $kvIndex, 'Keyvisual must come before preacher nametag'); - $this->assertLessThan($sermonIndex, $preacherIndex, 'Preacher nametag must come before sermon slides'); + $this->assertLessThan($introIndex, $songIndex); + $this->assertLessThan($sermonIndex, $introIndex, 'Prediger intro must come before sermon slides'); $songParser = $playlist->getEmbeddedSong('Großer Gott.pro'); $this->assertNotNull($songParser, 'Embedded song .pro missing'); diff --git a/tests/Feature/PlaylistSequenceTest.php b/tests/Feature/PlaylistSequenceTest.php index 41f06d4..a1c0630 100644 --- a/tests/Feature/PlaylistSequenceTest.php +++ b/tests/Feature/PlaylistSequenceTest.php @@ -55,25 +55,30 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded $entries = $playlist->getEntries(); $names = $this->entryNames($playlist); - $offset = $names[0] === 'Moderator' ? 1 : 0; - $this->assertSame(['Keyvisual-Predigt', 'Predigername', 'Predigt'], array_slice($names, $offset)); + // A moderator name tag may precede the sermon intro when the (factory-random) + // responsible person resolves to a moderator; skip it if present. + $offset = str_starts_with((string) ($names[0] ?? ''), 'Moderator') ? 1 : 0; + $this->assertSame(['Prediger - Erika Predigt', 'Predigt'], array_slice($names, $offset)); - $keyVisualSlides = $this->slidesForEntry($playlist, $entries[$offset]); - $this->assertCount(1, $keyVisualSlides); - $this->assertTrue($keyVisualSlides[0]->hasBackgroundMedia()); - $this->assertSame('KEY_VISUAL.jpg', $keyVisualSlides[0]->getBackgroundMediaUrl()); + // The sermon intro is ONE presentation with two cues: the key-visual alone, + // then the same key-visual with the preacher name tag rendered on top. + $introSlides = $this->slidesForEntry($playlist, $entries[$offset]); + $this->assertCount(2, $introSlides); + + $this->assertTrue($introSlides[0]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); $this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles()); - $nameTagSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]); - $this->assertCount(1, $nameTagSlides); - // Name and role are now split: the name is the main (\fs84) run and the + $this->assertTrue($introSlides[1]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); + // Name and role are split: the name is the main (\fs84) run and the // role is a separate smaller, non-bold (\b0\fs50) subtitle run. - [$name, $subtitle] = $this->nameTagNameAndSubtitle($nameTagSlides[0]); + [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]); $this->assertSame('Erika Predigt', $name); $this->assertSame('Predigt', $subtitle); - $this->assertTrue($nameTagSlides[0]->hasMacro()); + $this->assertTrue($introSlides[1]->hasMacro()); - $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 2]); + $sermonSlides = $this->slidesForEntry($playlist, $entries[$offset + 1]); $this->assertCount(2, $sermonSlides); $this->assertSame('sermon-1.jpg', $sermonSlides[0]->getLabel()); $this->assertSame('sermon-2.jpg', $sermonSlides[1]->getLabel()); @@ -117,7 +122,7 @@ public function test_moderator_nametag_is_first_presentation_for_first_visible_a $playlist = ProPlaylistReader::read($result['path']); $entries = $playlist->getEntries(); - $this->assertSame(['Moderator', 'Erstes sichtbares Lied'], $this->entryNames($playlist)); + $this->assertSame(['Moderator - Max Moderation', 'Erstes sichtbares Lied'], $this->entryNames($playlist)); $moderatorSlides = $this->slidesForEntry($playlist, $entries[0]); $this->assertCount(1, $moderatorSlides); // Name and role are split: the name is the main run, the role the subtitle run. @@ -203,6 +208,99 @@ public function test_without_moderator_name_no_moderator_nametag_is_added(): voi $this->cleanupTempDir($result['temp_dir']); } + public function test_nametag_presentations_are_named_by_role_and_person(): void + { + $this->configureNameTagMacro(); + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + Storage::disk('public')->put('slides/sermon.jpg', 'sermon-image'); + + $service = Service::factory()->create([ + 'title' => 'Rollen Benennung', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + 'moderator_name' => 'Max Moderation', + 'preacher_name_override' => 'Erika Predigt', + ]); + + $song = $this->createSongWithContent('Eröffnungslied'); + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'cts_song_name' => 'Eröffnungslied', + 'order' => 1, + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Eröffnungslied', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + 'is_before_event' => false, + ]); + $sermonItem = ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 2, + 'is_before_event' => false, + ]); + $this->createSermonSlide($service, $sermonItem, 'sermon.jpg', 0); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $names = $this->entryNames($playlist); + + $this->assertContains('Moderator - Max Moderation', $names); + $this->assertContains('Prediger - Erika Predigt', $names); + + $this->cleanupTempDir($result['temp_dir']); + } + + public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_no_images_presentation(): void + { + $this->configureNameTagMacro(); + Setting::set('agenda_sermon_matching', 'Predigt'); + Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image'); + + $service = Service::factory()->create([ + 'title' => 'Predigt ohne Folien', + 'date' => now(), + 'key_visual_filename' => 'slides/keyvisual.jpg', + 'preacher_name_override' => 'Erika Predigt', + ]); + ServiceAgendaItem::factory()->create([ + 'service_id' => $service->id, + 'title' => 'Predigt', + 'service_song_id' => null, + 'sort_order' => 1, + 'is_before_event' => false, + 'responsible' => [], + ]); + + $result = app(PlaylistExportService::class)->generatePlaylist($service); + $playlist = ProPlaylistReader::read($result['path']); + $entries = $playlist->getEntries(); + + // A sermon item with no uploaded slides emits ONLY the intro presentation. + $this->assertSame(['Prediger - Erika Predigt'], $this->entryNames($playlist)); + + // The intro .pro carries both cues: key-visual alone + key-visual with name tag. + $introSlides = $this->slidesForEntry($playlist, $entries[0]); + $this->assertCount(2, $introSlides); + $this->assertTrue($introSlides[0]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl()); + $this->assertTrue($introSlides[1]->hasBackgroundMedia()); + $this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl()); + [$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]); + $this->assertSame('Erika Predigt', $name); + $this->assertSame('Predigt', $subtitle); + $this->assertTrue($introSlides[1]->hasMacro()); + + // No separate sermon-images presentation is emitted. + $this->assertNull($playlist->getEmbeddedSong('Predigt.pro')); + + $this->cleanupTempDir($result['temp_dir']); + } + private function configureNameTagMacro(): void { Setting::set('namenseinblender_macro_name', 'Namenseinblender');