37 Commits
v0.2.0 ... main

Author SHA1 Message Date
1f256b236c fix(ci): Handle existing releases when re-releasing
All checks were successful
Create Release Package / build-release (push) Successful in 50s
- Check if release already exists for the tag
- Delete existing release before creating new one
- Allows re-releasing the same version after fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:55:35 +01:00
36a546c224 fix: Simplify localhost detection and add early metrics support
Some checks failed
Create Release Package / build-release (push) Failing after 53s
- License/Manager: Simplify localhost TLD detection to .localhost and .local
- Prometheus/Integration: Add collect_early_metrics() for wp-prometheus early mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:53:07 +01:00
d4e7c9127d docs: Update CLAUDE.md with v0.6.0 session learnings
- Added critical learning about never building local releases
- CI/CD pipeline handles all release building

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:51:52 +01:00
de9512815d docs: Add critical instruction to never build local releases
CI/CD pipeline handles all release building - local builds can corrupt
the development environment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:49:38 +01:00
379fd23be0 feat: Replace Twig with native PHP templates
All checks were successful
Create Release Package / build-release (push) Successful in 55s
- Remove twig/twig dependency from composer.json
- Convert all 25 Twig templates to native PHP templates
- New render() method in Plugin.php using PHP include with output buffering
- New render_partial() helper method for including partials
- Templates support theme overrides via fedistream/ directory
- Reduced plugin size by eliminating Twig and its dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:48:10 +01:00
6e45b0b6f1 docs: Update CLAUDE.md with v0.5.0/v0.5.1 session history
- Added Prometheus metrics integration documentation
- Added localhost license bypass documentation
- Updated memory leak status to resolved (fixed in WP Prometheus)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:53:52 +01:00
0627dd0db7 feat: Add localhost license bypass for development
All checks were successful
Create Release Package / build-release (push) Successful in 1m2s
- License check bypassed on localhost, 127.0.0.1, ::1
- Also bypassed for .local, .test, .localhost, .dev.local TLDs
- Bump version to 0.5.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:50:06 +01:00
df3b8a7ec2 feat: Add Prometheus metrics integration
All checks were successful
Create Release Package / build-release (push) Successful in 1m0s
- Add includes/Prometheus/Integration.php with metrics collection
- Expose content, engagement, user, WooCommerce, and ActivityPub metrics
- Add settings toggle in Integrations tab
- Requires WP Prometheus plugin to be active
- Bump version to 0.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:46:22 +01:00
d1597aa854 docs: Update CLAUDE.md with memory leak investigation session
- Documented v0.4.1 through v0.4.9 fix attempts
- Listed all protection mechanisms in place
- Recorded key learnings about WordPress hooks and recursion
- Status: unresolved, documented as known incompatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:10:09 +01:00
12f7d8f7b3 docs: Add known plugin incompatibilities section
- Added Known Issues section documenting WP Prometheus incompatibility
- Updated version badge to 0.4.9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:07:50 +01:00
bdc11d8769 revert: Restore conditional the_content filter usage
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
- Reverted nuclear option from v0.4.8
- get_post_data() now uses the_content filter conditionally
- All other protections remain in place
- Memory leak investigation to be continued later

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:05:59 +01:00
35ad390aeb fix: Nuclear option - never apply the_content filter
All checks were successful
Create Release Package / build-release (push) Successful in 1m2s
- get_post_data() now ALWAYS strips shortcodes and uses raw content
- Never calls apply_filters('the_content') or get_the_excerpt()
- FediStream posts don't need shortcode processing in content
- This guarantees no recursion through WordPress hook system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:59:04 +01:00
b592e45d58 fix: Hard main template rendering lock
Some checks failed
Create Release Package / build-release (push) Failing after 53s
- Added $rendering_main_template flag that blocks all other renders
- Reduced MAX_RENDER_DEPTH from 5 to 2
- template-wrapper.php passes is_main_template=true to enable hard lock
- Any render attempt during main template rendering is blocked

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:55:23 +01:00
a41eddbc49 fix: Block shortcode rendering during page template loading
All checks were successful
Create Release Package / build-release (push) Successful in 1m2s
- Added $loading_page_template flag in TemplateLoader
- template-wrapper.php sets flag before loading theme header/footer
- Shortcodes::render_template() returns early if flag is set
- Prevents recursion from theme components, widgets, or other plugins

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:09:16 +01:00
eb85870909 fix: Multi-layer protection against Twig rendering recursion
All checks were successful
Create Release Package / build-release (push) Successful in 57s
- Added render depth tracking in Plugin::render() with max depth of 5
- Strip shortcodes from content when in shortcode context
- Prevents any later do_shortcode() calls from triggering recursion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:04:38 +01:00
6988e49287 fix: Prevent get_the_excerpt() from triggering the_content filter
All checks were successful
Create Release Package / build-release (push) Successful in 58s
- get_the_excerpt() internally calls the_content filter when generating auto-excerpts
- When in shortcode context, now uses raw post_excerpt or wp_trim_words() instead
- This was the remaining recursion path causing memory exhaustion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:56:52 +01:00
166a5e6f7c fix: Complete memory leak fix for shortcode context handling
All checks were successful
Create Release Package / build-release (push) Successful in 58s
- Changed shortcode context from boolean to depth counter for nested shortcodes
- Added shortcode context protection to template-wrapper.php for single page views
- Fixes remaining recursion path in single FediStream post views

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:52:13 +01:00
fedab21c2a fix: Complete memory leak fix with shortcode context tracking
All checks were successful
Create Release Package / build-release (push) Successful in 57s
The v0.4.1 fix was incomplete - shortcodes called get_*_data() methods
directly, bypassing the recursion tracking in get_post_data().

Changes:
- Added $in_shortcode_context flag to TemplateLoader
- Added enter/exit_shortcode_context() methods
- All shortcode render methods now enter context before data loading
- When in shortcode context, the_content filter is always skipped

This fully prevents infinite recursion when post content contains
FediStream shortcodes that would otherwise recursively render.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:44:51 +01:00
eaefcff9c9 fix: Critical memory leak in TemplateLoader causing OOM errors
All checks were successful
Create Release Package / build-release (push) Successful in 57s
- Added recursion depth tracking to prevent infinite loops from shortcodes in content
- Nested items now skip the_content filter, using wp_kses_post() instead
- Made get_artist_data(), get_album_data(), get_track_data(), get_playlist_data() public
- Methods now accept both int post IDs and WP_Post objects
- Added $load_nested parameter to control nested item loading

Fixes memory exhaustion in Twig's StagingExtension when post content
contains FediStream shortcodes that trigger recursive template rendering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:37:43 +01:00
04201a66f8 docs: Update session history with CI/CD fixes
Document all CI/CD pipeline fixes:
- Gitea API for releases (not GitHub action)
- Git submodule with relative URL
- Composer path repository
- gettext installation
- SIGPIPE fix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:46:06 +01:00
8ae703787c chore: Reorder exclusions in release workflow
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:38:37 +01:00
c540cde0a4 docs: Update README for v0.4.0
- Update version badge to 0.4.0
- Add CI/CD badge
- Add release package installation instructions
- Add license key section
- Add releases section
- Update from-source instructions for submodules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:35:06 +01:00
d96e3e3a4d chore: Exclude .gitea and .gitmodules from release package
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:33:54 +01:00
20c879c065 fix: Handle SIGPIPE in package verification
All checks were successful
Create Release Package / build-release (push) Successful in 58s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:27:22 +01:00
d104b0ae46 fix: Install gettext for msgfmt in CI
Some checks failed
Create Release Package / build-release (push) Failing after 56s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:24:04 +01:00
98ddb63d44 fix: Use relative URL for submodule
Some checks failed
Create Release Package / build-release (push) Failing after 50s
Relative URL allows CI to use the same access method as the main repo,
avoiding networking issues with absolute URLs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:21:21 +01:00
3dd2f4d126 feat: Use git submodule for private dependency
Some checks failed
Create Release Package / build-release (push) Has been cancelled
- Add wc-licensed-product-client as git submodule in lib/
- Change Composer repository from VCS to path type
- CI now checks out submodules recursively
- Remove COMPOSER_AUTH (no longer needed)

This solves the CI networking issue by fetching the
dependency via git submodule instead of Composer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:18:14 +01:00
dfa405c89b fix: Add Composer auth for private repository in CI
Some checks failed
Create Release Package / build-release (push) Has been cancelled
Use COMPOSER_AUTH with HTTP basic auth to access private
wc-licensed-product-client repository during builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:11:05 +01:00
a7dbb7b4c5 fix: Use SRC_GITEA_TOKEN secret for releases
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:01:55 +01:00
efd3f7a170 chore: Update license client to ^0.2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:47:32 +01:00
077093765e chore: Update license client to dev-main
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:44:04 +01:00
d4425103ea fix: Use Gitea API directly for release creation
Some checks failed
Create Release Package / build-release (push) Failing after 3m6s
The actions/gitea-release-action doesn't exist on GitHub.
Use curl with Gitea API instead for reliable release creation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:31:53 +01:00
02e2ed82ef docs: Update session history for v0.4.0 CI/CD pipeline
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:43:34 +01:00
b21394c674 feat: Add Gitea Actions CI/CD pipeline for releases (v0.4.0)
Some checks failed
Create Release Package / build-release (push) Failing after 4s
- Automated release builds triggered by v* tags
- PHP 8.3 environment with production dependencies
- Automatic translation compilation (.po to .mo)
- Version verification (plugin version must match tag)
- WordPress-compliant zip structure
- SHA256 checksum generation
- Package structure verification
- Changelog extraction for release notes
- Automatic Gitea release creation with attachments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:40:08 +01:00
f3cd19efe0 feat: Add license management and tabbed settings (v0.3.0)
- Implement license management using magdev/wc-licensed-product-client
- Reorganize settings page into License, Default Settings, Integrations tabs
- Add license validation and activation via AJAX
- Frontend features require valid license (admin works always)
- Update translations with German (de_CH) for license strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:03:05 +01:00
d62f01cf41 docs: Update session history with v0.1.1 and v0.2.0 work
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:56:52 +01:00
edfd19dea1 docs: Fix markdown linting issues in user guide
- Consistent table column separator widths
- Rename duplicate "Requirements" headings to be more specific

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:56:13 +01:00
73 changed files with 9905 additions and 1657 deletions

View File

@@ -0,0 +1,210 @@
name: Create Release Package
on:
push:
tags:
- 'v*'
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, zip, intl, gettext
tools: composer:v2
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Validate composer.json
run: composer validate --strict
- name: Install Composer dependencies (production)
run: |
composer config platform.php 8.3.0
composer install --no-dev --optimize-autoloader --no-interaction
- name: Install gettext
run: apt-get update && apt-get install -y gettext
- name: Compile translations
run: |
for po in languages/*.po; do
if [ -f "$po" ]; then
mo="${po%.po}.mo"
echo "Compiling $po to $mo"
msgfmt -o "$mo" "$po"
fi
done
- name: Verify plugin version matches tag
run: |
PLUGIN_VERSION=$(grep -oP "Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+" wp-fedistream.php | head -1)
TAG_VERSION=${{ steps.version.outputs.version }}
if [ "$PLUGIN_VERSION" != "$TAG_VERSION" ]; then
echo "Error: Plugin version ($PLUGIN_VERSION) does not match tag version ($TAG_VERSION)"
exit 1
fi
echo "Version verified: $PLUGIN_VERSION"
- name: Create release directory
run: mkdir -p releases
- name: Build release package
run: |
VERSION=${{ steps.version.outputs.version }}
PLUGIN_NAME="wp-fedistream"
RELEASE_FILE="releases/${PLUGIN_NAME}-${VERSION}.zip"
# Move to parent directory for proper zip structure
cd ..
# Create zip with proper WordPress plugin structure
zip -r "${PLUGIN_NAME}/${RELEASE_FILE}" "${PLUGIN_NAME}" \
-x "${PLUGIN_NAME}/.git/*" \
-x "${PLUGIN_NAME}/.gitea/*" \
-x "${PLUGIN_NAME}/.github/*" \
-x "${PLUGIN_NAME}/.vscode/*" \
-x "${PLUGIN_NAME}/.claude/*" \
-x "${PLUGIN_NAME}/.gitea/*" \
-x "${PLUGIN_NAME}/CLAUDE.md" \
-x "${PLUGIN_NAME}/wp-core" \
-x "${PLUGIN_NAME}/wp-core/*" \
-x "${PLUGIN_NAME}/wp-plugins" \
-x "${PLUGIN_NAME}/wp-plugins/*" \
-x "${PLUGIN_NAME}/releases/*" \
-x "${PLUGIN_NAME}/composer.lock" \
-x "${PLUGIN_NAME}/*.log" \
-x "${PLUGIN_NAME}/.gitignore" \
-x "${PLUGIN_NAME}/.gitmodules" \
-x "${PLUGIN_NAME}/.editorconfig" \
-x "${PLUGIN_NAME}/phpcs.xml*" \
-x "${PLUGIN_NAME}/phpunit.xml*" \
-x "${PLUGIN_NAME}/tests/*" \
-x "${PLUGIN_NAME}/*.po~" \
-x "${PLUGIN_NAME}/*.bak" \
-x "*.DS_Store"
cd "${PLUGIN_NAME}"
echo "Created: ${RELEASE_FILE}"
- name: Generate checksums
run: |
VERSION=${{ steps.version.outputs.version }}
RELEASE_FILE="releases/wp-fedistream-${VERSION}.zip"
cd releases
sha256sum "wp-fedistream-${VERSION}.zip" > "wp-fedistream-${VERSION}.zip.sha256"
echo "SHA256:"
cat "wp-fedistream-${VERSION}.zip.sha256"
- name: Verify package structure
run: |
set +o pipefail
VERSION=${{ steps.version.outputs.version }}
echo "Package contents:"
unzip -l "releases/wp-fedistream-${VERSION}.zip" | head -50 || true
# Verify main file is at correct location
if unzip -l "releases/wp-fedistream-${VERSION}.zip" | grep -q "wp-fedistream/wp-fedistream.php"; then
echo "✓ Main plugin file at correct location"
else
echo "✗ Error: Main plugin file not found at wp-fedistream/wp-fedistream.php"
exit 1
fi
# Verify vendor directory is included
if unzip -l "releases/wp-fedistream-${VERSION}.zip" | grep -q "wp-fedistream/vendor/"; then
echo "✓ Vendor directory included"
else
echo "✗ Error: Vendor directory not found"
exit 1
fi
- name: Extract changelog for release notes
id: changelog
run: |
VERSION=${{ steps.version.outputs.version }}
# Extract changelog section for this version
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | sed '$ d' | tail -n +2)
if [ -z "$NOTES" ]; then
NOTES="Release version ${VERSION}"
fi
# Save to file for multi-line output
echo "$NOTES" > release_notes.txt
echo "Release notes extracted"
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.SRC_GITEA_TOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
TAG_NAME=${{ github.ref_name }}
PRERELEASE="false"
if [[ "$TAG_NAME" == *-* ]]; then
PRERELEASE="true"
fi
# Read release notes
BODY=$(cat release_notes.txt)
# Check if release already exists for this tag
EXISTING_RELEASE=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG_NAME}")
EXISTING_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id // empty')
if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
echo "Release already exists for tag ${TAG_NAME} (ID: $EXISTING_ID). Deleting..."
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${EXISTING_ID}"
echo "Deleted existing release."
fi
# Create release via Gitea API
RELEASE_RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${VERSION}\", \"body\": $(echo "$BODY" | jq -Rs .), \"draft\": false, \"prerelease\": ${PRERELEASE}}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
if [ "$RELEASE_ID" == "null" ] || [ -z "$RELEASE_ID" ]; then
echo "Failed to create release:"
echo "$RELEASE_RESPONSE"
exit 1
fi
echo "Created release ID: $RELEASE_ID"
# Upload attachments
for file in "releases/wp-fedistream-${VERSION}.zip" "releases/wp-fedistream-${VERSION}.zip.sha256"; do
if [ -f "$file" ]; then
FILENAME=$(basename "$file")
echo "Uploading $FILENAME..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${FILENAME}"
echo "Uploaded $FILENAME"
fi
done
echo "Release created successfully: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${TAG_NAME}"

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "lib/wc-licensed-product-client"]
path = lib/wc-licensed-product-client
url = ../wc-licensed-product-client.git

View File

@@ -7,6 +7,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.0] - 2026-02-02
### Changed
- **Replaced Twig with native PHP templates** - Major refactoring to remove external dependency
- Removed `twig/twig` from composer.json dependencies
- All 25 Twig templates converted to native PHP templates
- New `render()` method in Plugin.php uses PHP include with output buffering
- New `render_partial()` helper method for including partials from within templates
- Templates support theme overrides via `fedistream/` directory in themes
- Improved performance by eliminating Twig compilation overhead
- Reduced plugin size by removing Twig and its dependencies (symfony/polyfill-ctype, symfony/polyfill-mbstring)
### Removed
- Twig template engine dependency
- All `.twig` template files (replaced with `.php` equivalents)
- Twig-related initialization code in Plugin.php
## [0.5.1] - 2026-02-02
### Added
- **Localhost license bypass** - License check is automatically bypassed on local development environments
- Detects `localhost`, `127.0.0.1`, `::1`
- Detects common local TLDs: `.local`, `.test`, `.localhost`, `.dev.local`
- Allows full plugin functionality without license on development sites
## [0.5.0] - 2026-02-02
### Added
- **Prometheus Metrics Integration** - Expose FediStream metrics for monitoring
- Content metrics: `fedistream_content_total` (by type/status), `fedistream_genres_total`, `fedistream_moods_total`
- Engagement metrics: `fedistream_plays_total`, `fedistream_plays_today`, `fedistream_favorites_total`, `fedistream_local_follows_total`, `fedistream_listening_history_entries`
- User metrics: `fedistream_users_with_library`, `fedistream_users_following_artists`, `fedistream_notifications_total`, `fedistream_notifications_pending`
- WooCommerce metrics (conditional): `fedistream_purchases_total`, `fedistream_customers_total`, `fedistream_products_total`
- ActivityPub metrics (conditional): `fedistream_activitypub_followers_total`, `fedistream_activitypub_followers_by_artist`, `fedistream_activitypub_reactions_total`
- New setting in Integrations tab to enable/disable Prometheus metrics
- Requires WP Prometheus plugin to be active
## [0.4.9] - 2026-02-02
### Changed
- **Reverted nuclear option** - Restored conditional the_content filter usage
- `get_post_data()` now uses the_content filter only when NOT in shortcode context, NOT at depth > 1, and NOT loading page template
- All other protections remain in place (render depth, page template loading flag, main template lock, shortcode context)
- Memory leak investigation to be continued later
## [0.4.8] - 2026-02-02
### Fixed
- **Nuclear option: NEVER apply the_content filter** - Completely removed the_content filter usage (reverted in 0.4.9)
- `get_post_data()` now ALWAYS strips shortcodes and uses raw content
- NEVER calls `apply_filters('the_content', ...)` or `get_the_excerpt()`
- FediStream posts don't need shortcode processing in their content anyway
- This guarantees no recursion through WordPress hook system
## [0.4.7] - 2026-02-02
### Fixed
- **Hard main template rendering lock** - Added additional protection at Plugin::render() level
- Added `$rendering_main_template` flag that completely blocks any other render calls while main template is rendering
- Reduced MAX_RENDER_DEPTH from 5 to 2 (allows one level of {% include %} but prevents deeper recursion)
- template-wrapper.php now passes `is_main_template = true` to enable the hard lock
- Any render attempt during main template rendering is immediately blocked
## [0.4.6] - 2026-02-02
### Fixed
- **Page template loading lock** - Block ALL shortcode rendering during page template loading
- Added `$loading_page_template` flag in TemplateLoader
- template-wrapper.php now sets this flag before loading theme header/footer
- Shortcodes::render_template() checks this flag and returns early if set
- This prevents any recursion triggered by theme components, widgets, or other plugins during page template loading
- Main template rendering still works (uses Plugin::render() directly, not through Shortcodes)
## [0.4.5] - 2026-02-02
### Fixed
- **Multi-layer recursion protection** - Added additional safeguards against infinite Twig rendering
- Added render depth tracking in `Plugin::render()` with max depth of 5
- Strip shortcodes from content when in shortcode context (prevents any later `do_shortcode()` calls from triggering recursion)
- This addresses the Twig StagingExtension.php recursion error
## [0.4.4] - 2026-02-02
### Fixed
- **Fix excerpt-triggered recursion** - `get_the_excerpt()` internally calls `the_content` filter when generating auto-excerpts
- When in shortcode context, now uses raw `$post->post_excerpt` or generates simple excerpt with `wp_trim_words()` instead
- This was the remaining recursion path causing memory exhaustion in `class-wp-hook.php`
## [0.4.3] - 2026-02-02
### Fixed
- **Further memory leak fix** - v0.4.2 fix was still incomplete
- Changed `$in_shortcode_context` boolean to `$shortcode_context_depth` counter to properly handle nested shortcodes
- Added shortcode context protection to `template-wrapper.php` for single page views
- This fixes the remaining recursion path where `the_content` filter was still being applied when viewing single FediStream posts (artists, albums, tracks, playlists)
## [0.4.2] - 2026-02-02
### Fixed
- **Complete fix for memory leak** - v0.4.1 fix was incomplete
- Added `$in_shortcode_context` flag to TemplateLoader to track when we're rendering shortcodes
- All shortcode render methods now call `enter_shortcode_context()` before loading data
- When in shortcode context, `the_content` filter is always skipped to prevent recursive shortcode processing
- This prevents infinite recursion when post content contains FediStream shortcodes
## [0.4.1] - 2026-02-02
### Fixed
- **Critical memory leak** causing "Allowed memory size exhausted" errors in Twig's StagingExtension
- Root cause: `apply_filters('the_content')` in `get_post_data()` triggered shortcode processing, causing infinite recursion when post content contained FediStream shortcodes
- Added recursion depth tracking with `MAX_RECURSION_DEPTH = 3` to prevent runaway nesting
- Nested items now skip `the_content` filter, using `wp_kses_post()` instead
- Nested data loading (albums within artists, tracks within albums) is now properly bounded
### Changed
- Made `get_artist_data()`, `get_album_data()`, `get_track_data()`, and `get_playlist_data()` public methods in TemplateLoader (previously private but called externally)
- These methods now accept both `int` post IDs and `WP_Post` objects for flexibility
- Added `$load_nested` parameter to control whether nested items are fully loaded or just counted
## [0.4.0] - 2026-01-29
### Added
- Gitea Actions CI/CD pipeline for automated release package creation
- Triggered by `v*` tags
- PHP 8.3 environment with production dependencies
- Automatic translation compilation (.po to .mo)
- Version verification (plugin version must match tag)
- WordPress-compliant zip structure
- SHA256 checksum generation
- Package structure verification
- Changelog extraction for release notes
- Automatic Gitea release creation with attachments
- Pre-release detection for tags containing `-`
## [0.3.0] - 2026-01-29
### Added
- License management integration using `magdev/wc-licensed-product-client` package
- Tabbed settings page with License, Default Settings, and Integrations tabs
- License validation and activation via AJAX with real-time status updates
- License status banner showing current license state and expiration
- License checks for frontend components (unlicensed sites show message instead of content)
### Changed
- Reorganized settings page into three tabs for better organization
- Frontend features (player, shortcodes, ActivityPub) now require valid license
- Admin/backend functionality works regardless of license status
### Security
- Server secret stored securely in WordPress options
- HMAC signature verification for license server responses
- Nonce verification for all license AJAX operations
## [0.2.0] - 2026-01-28 ## [0.2.0] - 2026-01-28
### Added ### Added
@@ -153,7 +324,21 @@ Initial release of WP FediStream - a WordPress plugin for streaming music over A
--- ---
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.2.0...HEAD [Unreleased]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.6.0...HEAD
[0.6.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.5.1...v0.6.0
[0.5.1]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.5.0...v0.5.1
[0.5.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.9...v0.5.0
[0.4.9]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.8...v0.4.9
[0.4.8]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.7...v0.4.8
[0.4.7]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.6...v0.4.7
[0.4.6]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.5...v0.4.6
[0.4.5]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.4...v0.4.5
[0.4.4]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.3...v0.4.4
[0.4.3]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.2...v0.4.3
[0.4.2]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.1...v0.4.2
[0.4.1]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.4.0...v0.4.1
[0.4.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.3.0...v0.4.0
[0.3.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.2.0...v0.3.0
[0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.1...v0.2.0 [0.2.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.1...v0.2.0
[0.1.1]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.0...v0.1.1 [0.1.1]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/compare/v0.1.0...v0.1.1
[0.1.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases/tag/v0.1.0 [0.1.0]: https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases/tag/v0.1.0

357
CLAUDE.md
View File

@@ -24,16 +24,14 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
### Version 0.2.1 (Bugfix) (No pending features - all roadmap items completed)
### Version 0.3.0 (Minor)
## Technical Stack ## Technical Stack
- **Language:** PHP 8.3.x - **Language:** PHP 8.3.x
- **Framework:** Latest WordPress Plugin API - **Framework:** Latest WordPress Plugin API
- **E-commerce (optional):** WooCommerce 10.0+ - **E-commerce (optional):** WooCommerce 10.0+
- **Template Engine:** Twig 3.0 (via Composer) - **Template Engine:** Native PHP templates (with theme override support)
- **Communication Protocol:** ActivityPub - **Communication Protocol:** ActivityPub
- **Wordpress Base Theme** twentytwentyfive - **Wordpress Base Theme** twentytwentyfive
- **Frontend:** Vanilla JavaScript - **Frontend:** Vanilla JavaScript
@@ -81,28 +79,27 @@ for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
### Create releases ### Create releases
**CRITICAL: NEVER build release packages locally! All releases are built automatically by the CI/CD pipeline (Gitea Actions) when a tag is pushed. Local builds can corrupt the development environment and create inconsistent packages.**
To create a release:
1. Update version in `wp-fedistream.php` (both header and constant)
2. Update `CHANGELOG.md` with release notes
3. Commit changes to `main` branch
4. Create annotated tag: `git tag -a v0.6.0 -m "Release v0.6.0"`
5. Push: `git push origin main --tags`
6. CI/CD will automatically build and publish the release
#### CI/CD Build Details (for reference only)
The following details describe what the CI/CD pipeline does - DO NOT run these locally:
- The `vendor/` directory MUST be included in releases (Dependencies required for runtime) - The `vendor/` directory MUST be included in releases (Dependencies required for runtime)
- **Don't create any release files until version 0.1.x and up!** - Build `vendor/` for the MINIMUM supported PHP version (8.3.0)
- **CRITICAL**: Build `vendor/` for the MINIMUM supported PHP version, not the development version - WordPress requires plugins in a subdirectory structure
- Use `composer config platform.php 8.3.0` before building release packages - Symlinks are excluded from packages
- Run `composer update --no-dev --optimize-autoloader` to rebuild dependencies
- **CRITICAL**: WordPress requires plugins in a subdirectory structure
- Run zip from the `plugins/` parent directory, NOT from within the plugin directory
- Package must extract to `wp-fedistream/` subdirectory with main file at `wp-fedistream/wp-fedistream.php`
- Correct command: `cd /wp-content/plugins/ && zip -r wp-fedistream/releases/wp-fedistream-x.x.x.zip wp-fedistream ...`
- Wrong: Running zip from inside the plugin directory creates files at root level
- **CRITICAL**: Exclude symlinks explicitly - zip follows symlinks by default
- Always use `-x "wp-fedistream/wp-core" -x "wp-fedistream/wp-core/*" -x "wp-fedistream/wp-plugins" -x "wp-fedistream/wp-plugins/*"` to exclude development symlinks
- Otherwise the entire linked directory contents will be included in the package
- Exclusion patterns must match the relative path structure used in zip command
- Always verify the package structure with `unzip -l` before distribution
- Check all files are prefixed with `wp-fedistream/`
- Verify main file is at `wp-fedistream/wp-fedistream.php`
- Check for duplicate entries (indicates multiple builds in same archive)
- Test installation on the minimum supported PHP version before final deployment
- Releases are stored in `releases/` including checksums - Releases are stored in `releases/` including checksums
- Track release changes in a single `CHANGELOG.md` file
- Bump the version number to either bugfix release versions or on new features minor release versions
- **CRITICAL**: WordPress reads version from TWO places - BOTH must be updated: - **CRITICAL**: WordPress reads version from TWO places - BOTH must be updated:
1. Plugin header comment `Version: x.x.x` (line ~6 in wc-licensed-product.php) - WordPress uses THIS for admin display 1. Plugin header comment `Version: x.x.x` (line ~6 in wc-licensed-product.php) - WordPress uses THIS for admin display
2. PHP constant `WP_FEDISTREAM_VERSION` (line ~28) - Used internally by the plugin 2. PHP constant `WP_FEDISTREAM_VERSION` (line ~28) - Used internally by the plugin
@@ -128,11 +125,48 @@ for po in languages/*.po; do msgfmt -o "${po%.po}.mo" "$po"; done
#### What's Excluded #### What's Excluded
- Git metadata (`.git/`) - Git metadata (`.git/`)
- Development files (`.vscode/`, `.claude/`, `CLAUDE.md`, `wp-core`, `wp-plugins`) - Development files (`.vscode/`, `.claude/`, `.gitea/`, `CLAUDE.md`, `wp-core`, `wp-plugins`)
- Logs and cache files - Logs and cache files
- Previous releases - Previous releases
- `composer.lock` (but `vendor/` is included) - `composer.lock` (but `vendor/` is included)
#### CI/CD Pipeline (Gitea Actions)
Automated release packages are created via Gitea Actions when a tag matching `v*` is pushed:
**Workflow:** `.gitea/workflows/release.yml`
**Trigger:** Push tag `vX.X.X` to repository
**Steps:**
1. Checkout code
2. Setup PHP 8.3 with required extensions
3. Install production Composer dependencies
4. Compile translations (.po to .mo)
5. Verify plugin version matches tag version
6. Build release zip with proper WordPress structure
7. Generate SHA256 checksums
8. Verify package structure
9. Extract changelog for release notes
10. Create Gitea release with attachments
**Required Secret:** `GITEA_TOKEN` - Personal access token with release permissions
**Pre-release Detection:** Tags containing `-` (e.g., `v1.0.0-beta`) are marked as pre-release
**To create a release:**
```bash
# Ensure version is updated in wp-fedistream.php (both header and constant)
git checkout main
git merge dev
git tag -a v0.4.0 -m "Release v0.4.0"
git push origin main --tags
```
The pipeline will automatically build and publish the release package.
--- ---
**For AI Assistants:** **For AI Assistants:**
@@ -186,6 +220,9 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
```txt ```txt
wp-fedistream/ wp-fedistream/
├── .gitea/
│ └── workflows/
│ └── release.yml # CI/CD release pipeline
├── assets/ ├── assets/
│ ├── css/ │ ├── css/
│ │ ├── admin.css # Admin interface styles │ │ ├── admin.css # Admin interface styles
@@ -397,3 +434,273 @@ wp-fedistream/
- Successfully pushed dev, main branches and v0.1.0 tag to origin - Successfully pushed dev, main branches and v0.1.0 tag to origin
- Remote URL updated from HTTPS to SSH for authentication - Remote URL updated from HTTPS to SSH for authentication
- First release is now live at the repository - First release is now live at the repository
### 2026-01-28 - Bugfix v0.1.1 and Feature v0.2.0
**Summary:** Fixed WooCommerce integration timing bug, added plugin action links and user guide.
**v0.1.1 - Bugfix:**
- Fixed WooCommerce product types not appearing in product selector
- Root cause: `Integration` constructor hooked `check_woocommerce` to `plugins_loaded` priority 5, but class was instantiated at priority 10 (too late)
- Solution: Call `check_woocommerce()` directly in constructor
**v0.2.0 - Features:**
- Added Dashboard and Settings links to WordPress Plugins page
- Created comprehensive `USERGUIDE.md` covering all features
**Files Modified:**
- `includes/WooCommerce/Integration.php` - Fixed hook timing
- `includes/Plugin.php` - Added `add_plugin_action_links()` method
**Files Created:**
- `USERGUIDE.md` - Comprehensive user documentation
**Notes:**
- All releases pushed to origin (v0.1.1 and v0.2.0 tags)
- Markdown linting fixes applied to USERGUIDE.md
### 2026-01-29 - License Management v0.3.0
**Summary:** Implemented license management integration and reorganized settings page into tabs.
**Features:**
- License management using `magdev/wc-licensed-product-client` package
- Tabbed settings page: License, Default Settings, Integrations
- License validation and activation via AJAX
- License status banner with expiration display
- Frontend license checks (unlicensed sites show message instead of content)
- Admin/backend works regardless of license status
**License Behavior:**
- Backend (admin): Full access always
- Frontend (player, shortcodes, ActivityPub): Requires valid license
**Files Created:**
- `includes/License/Manager.php` - License management wrapper class
**Files Modified:**
- `composer.json` - Added VCS repository and `magdev/wc-licensed-product-client` dependency
- `includes/Plugin.php` - Tabbed settings page, license manager initialization, conditional frontend loading
- `includes/Installer.php` - Added default license options
- `includes/Frontend/Shortcodes.php` - Added unlicensed mode support
- `includes/Frontend/Ajax.php` - Added license checks to public AJAX endpoints
- `assets/js/admin.js` - License validation AJAX handlers
- `assets/css/admin.css` - Tab and license status styling
- `wp-fedistream.php` - Version bump to 0.3.0
- `CHANGELOG.md` - Added v0.3.0 entry
**Notes:**
- Package name is `magdev/wc-licensed-product-client` (not `wc-license-product-client`)
- Uses Symfony HTTP Client via the license client package
- License validation cached for 24 hours using WordPress transients
### 2026-01-29 - CI/CD Pipeline v0.4.0
**Summary:** Added Gitea Actions workflow for automated release package creation with multiple iterations to fix CI issues.
**Features:**
- Automated release builds triggered by `v*` tags
- PHP 8.3 environment with required extensions
- Production Composer dependency installation
- Automatic translation compilation (.po to .mo)
- Version verification (plugin version must match tag)
- Proper WordPress plugin zip structure
- SHA256 checksum generation
- Package structure verification
- Changelog extraction for release notes
- Automatic Gitea release creation via API
- Pre-release detection for tags containing `-`
**Files Created:**
- `.gitea/workflows/release.yml` - CI/CD release pipeline
- `.gitmodules` - Git submodule configuration
- `lib/wc-licensed-product-client/` - Submodule for private dependency
**Files Modified:**
- `CLAUDE.md` - Added CI/CD documentation and updated directory structure
- `CHANGELOG.md` - Added v0.4.0 entry
- `wp-fedistream.php` - Version bump to 0.4.0
- `composer.json` - Changed to path repository for submodule
- `README.md` - Updated for v0.4.0, added release/installation docs
**CI/CD Fixes Applied:**
1. `actions/gitea-release-action@v1` doesn't exist - use Gitea API directly with curl
2. Private repo network issue - use git submodule with relative URL (`../wc-licensed-product-client.git`)
3. Composer path repository for submodule dependency
4. `msgfmt` not found - install gettext package
5. SIGPIPE error (exit 141) - use `set +o pipefail` and `|| true`
**Notes:**
- Requires `SRC_GITEA_TOKEN` secret configured in repository settings
- Uses `shivammathur/setup-php@v2` for PHP setup
- Uses Gitea API directly for release creation (not GitHub Actions)
- Submodule uses relative URL for CI compatibility
- Composer symlinks from `lib/wc-licensed-product-client` to vendor
### 2026-02-02 - Memory Leak Investigation v0.4.1 through v0.4.9
**Summary:** Investigated memory exhaustion issue when WP FediStream is used with WP Prometheus plugin. Multiple fix attempts were made but the root cause remains unresolved.
**Problem:**
- PHP Fatal error: Allowed memory size exhausted (1GB)
- Error locations varied: Twig StagingExtension.php, Environment.php, ExtensionSet.php, WordPress class-wp-hook.php
- Only occurs when WP Prometheus plugin is also active
- Suspected infinite recursion through WordPress hook system
**Fix Attempts (v0.4.1 - v0.4.8):**
1. **v0.4.1** - Added recursion depth tracking in `get_post_data()`, skip `the_content` filter at depth > 1
2. **v0.4.2** - Added `$in_shortcode_context` flag, all shortcode render methods enter context before data loading
3. **v0.4.3** - Changed boolean to counter (`$shortcode_context_depth`), added context to `template-wrapper.php`
4. **v0.4.4** - Fixed `get_the_excerpt()` which internally triggers `the_content` filter
5. **v0.4.5** - Added render depth tracking in `Plugin::render()`, strip shortcodes from content
6. **v0.4.6** - Added `$loading_page_template` flag to block shortcode rendering during page template loading
7. **v0.4.7** - Added `$rendering_main_template` hard lock in `Plugin::render()`, reduced MAX_RENDER_DEPTH to 2
8. **v0.4.8** - Nuclear option: ALWAYS skip `the_content` filter (didn't work, reverted)
**v0.4.9 - Current State:**
- Reverted nuclear option
- Kept all other protections in place
- Issue documented in README.md as known incompatibility
**Files Modified:**
- `includes/Frontend/TemplateLoader.php` - Multiple recursion protection mechanisms
- `includes/Frontend/Shortcodes.php` - Shortcode context entry, page template loading check
- `includes/Frontend/template-wrapper.php` - Page template loading flag, main template render flag
- `includes/Plugin.php` - Render depth tracking, main template rendering lock
- `README.md` - Added Known Issues section
**Protection Mechanisms in Place:**
1. `$recursion_depth` counter in `get_post_data()` (max 3)
2. `$shortcode_context_depth` counter for nested shortcodes
3. `$loading_page_template` flag blocks shortcode rendering during page load
4. `$rendering_main_template` flag in `Plugin::render()` blocks parallel renders
5. `MAX_RENDER_DEPTH = 2` in `Plugin::render()`
6. Skip `the_content` and `get_the_excerpt()` when in protected context
7. Strip shortcodes from content when skipping content filter
**Key Learnings:**
- `get_the_excerpt()` internally calls `apply_filters('the_content', ...)` when generating auto-excerpts
- `the_content` filter triggers `do_shortcode()` which can cause recursive shortcode processing
- WordPress hook system (class-wp-hook.php) can itself be the recursion point
- The interaction between FediStream and WP Prometheus appears to be at a fundamental WordPress level
**Status:** RESOLVED - Issue was fixed in WP Prometheus plugin, removed from known incompatibilities
### 2026-02-02 - Prometheus Metrics & License Bypass v0.5.0/v0.5.1
**Summary:** Added Prometheus metrics integration and localhost license bypass for development.
**v0.5.0 - Prometheus Metrics Integration:**
- Created `includes/Prometheus/Integration.php` with comprehensive metrics collection
- Content metrics: `fedistream_content_total`, `fedistream_genres_total`, `fedistream_moods_total`
- Engagement metrics: `fedistream_plays_total`, `fedistream_plays_today`, `fedistream_favorites_total`, `fedistream_local_follows_total`, `fedistream_listening_history_entries`
- User metrics: `fedistream_users_with_library`, `fedistream_users_following_artists`, `fedistream_notifications_total`, `fedistream_notifications_pending`
- WooCommerce metrics (conditional): `fedistream_purchases_total`, `fedistream_customers_total`, `fedistream_products_total`
- ActivityPub metrics (conditional): `fedistream_activitypub_followers_total`, `fedistream_activitypub_followers_by_artist`, `fedistream_activitypub_reactions_total`
- New setting in Integrations tab to enable/disable
- Uses `wp_prometheus_collect_metrics` action hook
**v0.5.1 - Localhost License Bypass:**
- Added `is_localhost()` method to License Manager
- Bypasses license check on: `localhost`, `127.0.0.1`, `::1`
- Also bypasses for TLDs: `.local`, `.test`, `.localhost`, `.dev.local`
- Allows full plugin functionality during local development
**Files Created:**
- `includes/Prometheus/Integration.php` - Metrics collection class
- `includes/Prometheus/index.php` - Security file
**Files Modified:**
- `includes/Plugin.php` - Added Prometheus integration, settings toggle, `is_prometheus_active()` method
- `includes/Installer.php` - Added `wp_fedistream_enable_prometheus` default option
- `includes/License/Manager.php` - Added `is_localhost()` method, bypass in `is_license_valid()`
- `README.md` - Removed known incompatibility section, updated version badge
**Integration Pattern:**
Follows same pattern as WooCommerce/ActivityPub integrations:
```php
if (get_option('wp_fedistream_enable_prometheus', 0) && $this->is_prometheus_active()) {
new PrometheusIntegration();
}
```
**Notes:**
- WP Prometheus memory leak issue was fixed in WP Prometheus itself
- Metrics use simple COUNT queries for performance
- ActivityPub followers limited to top 10 to avoid cardinality explosion
- Default disabled (opt-in via Settings > Integrations)
### 2026-02-02 - Native PHP Templates v0.6.0
**Summary:** Removed Twig dependency and converted all templates to native PHP for improved performance and reduced dependencies.
**Changes:**
- Removed `twig/twig` from composer.json dependencies
- Converted all 25 Twig templates to native PHP templates
- Updated `Plugin.php` with new `render()` method using PHP include with output buffering
- Added `render_partial()` helper method for including partials from within templates
- Added `get_template_path()` method supporting theme overrides via `fedistream/` directory
- Reduced plugin package size by removing Twig and its dependencies
**Files Modified:**
- `includes/Plugin.php` - Removed Twig initialization, new native PHP render methods
- `includes/Frontend/template-wrapper.php` - Updated docblock
- `composer.json` - Removed `twig/twig` dependency
**Templates Created (25 PHP files replacing Twig):**
- `templates/partials/` - card-artist.php, card-album.php, card-track.php, card-playlist.php
- `templates/single/` - artist.php, album.php, track.php, playlist.php
- `templates/archive/` - artist.php, album.php, track.php, playlist.php, taxonomy.php
- `templates/shortcodes/` - artist.php, album.php, track.php, playlist.php, player.php, releases-grid.php, tracks-list.php, artists-grid.php
- `templates/widgets/` - recent-releases.php, popular-tracks.php, featured-artist.php, now-playing.php
**Templates Deleted (25 Twig files):**
- All `.twig` files in templates directory
**Benefits:**
- No external template engine dependency
- Improved performance (no Twig compilation)
- Native WordPress template overriding support
- Smaller plugin package size
- Simpler debugging with standard PHP
**Critical Learning - NEVER Build Local Releases:**
- Attempted to run `composer update --no-dev` locally for release building
- This corrupts the development environment and creates inconsistent packages
- **All releases must be built by CI/CD pipeline only**
- The "Create releases" section in this document has been updated with this critical instruction
- To create a release: commit, tag, push - CI/CD handles the rest

View File

@@ -2,10 +2,11 @@
Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels. Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels.
[![Version](https://img.shields.io/badge/version-0.2.0-blue.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-0.6.0-blue.svg)](CHANGELOG.md)
[![PHP](https://img.shields.io/badge/PHP-%3E%3D8.3-purple.svg)](https://php.net) [![PHP](https://img.shields.io/badge/PHP-%3E%3D8.3-purple.svg)](https://php.net)
[![WordPress](https://img.shields.io/badge/WordPress-%3E%3D6.4-blue.svg)](https://wordpress.org) [![WordPress](https://img.shields.io/badge/WordPress-%3E%3D6.4-blue.svg)](https://wordpress.org)
[![License](https://img.shields.io/badge/license-GPL--2.0%2B-green.svg)](https://www.gnu.org/licenses/gpl-2.0.html) [![License](https://img.shields.io/badge/license-GPL--2.0%2B-green.svg)](https://www.gnu.org/licenses/gpl-2.0.html)
[![CI/CD](https://img.shields.io/badge/CI%2FCD-Gitea%20Actions-green.svg)](https://src.bundespruefstelle.ch/magdev/wp-fedistream/actions)
## Description ## Description
@@ -31,29 +32,47 @@ WP FediStream is a WordPress plugin that enables musicians, bands, and labels to
- PHP 8.3 or higher - PHP 8.3 or higher
- WordPress 6.4 or higher - WordPress 6.4 or higher
- Composer (for development/installation) - Valid license key (required for frontend features)
### Optional ### Optional
- [ActivityPub Plugin](https://wordpress.org/plugins/activitypub/) - For Fediverse integration - [ActivityPub Plugin](https://wordpress.org/plugins/activitypub/) - For Fediverse integration
- [WooCommerce](https://woocommerce.com/) 10.0+ - For selling music - [WooCommerce](https://woocommerce.com/) 10.0+ - For selling music
## License Key
WP FediStream requires a valid license key for frontend functionality (player, shortcodes, ActivityPub). The admin dashboard works without a license, allowing you to configure the plugin before activation.
To obtain a license key, contact the author or purchase from the official website.
## Installation ## Installation
### From Source ### From Release Package (Recommended)
1. Clone or download the repository to your WordPress plugins directory: 1. Download the latest release from the [Releases page](https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases)
2. Upload the ZIP file via **Plugins > Add New > Upload Plugin** in WordPress admin
3. Activate the plugin under **Plugins > Installed Plugins**
4. Navigate to **FediStream > Settings** and enter your license key
5. Start using the plugin via the **FediStream** admin menu
### From Source (Development)
1. Clone the repository to your WordPress plugins directory:
```bash ```bash
cd wp-content/plugins/ cd wp-content/plugins/
git clone https://src.bundespruefstelle.ch/magdev/wp-fedistream.git git clone --recurse-submodules https://src.bundespruefstelle.ch/magdev/wp-fedistream.git
``` ```
2. Install Composer dependencies: 2. Install Composer dependencies:
```bash ```bash
cd wp-fedistream cd wp-fedistream
composer install --no-dev composer install
``` ```
3. Activate the plugin in WordPress admin under **Plugins > Installed Plugins** 3. Activate the plugin in WordPress admin under **Plugins > Installed Plugins**
@@ -127,12 +146,22 @@ wp-fedistream/
│ ├── Plugin.php # Main plugin singleton │ ├── Plugin.php # Main plugin singleton
│ └── Installer.php # Activation/deactivation │ └── Installer.php # Activation/deactivation
├── languages/ # Translation files ├── languages/ # Translation files
├── templates/ # Twig templates ├── templates/ # PHP templates
├── vendor/ # Composer dependencies ├── vendor/ # Composer dependencies
├── CHANGELOG.md # Version history ├── CHANGELOG.md # Version history
└── wp-fedistream.php # Plugin entry point └── wp-fedistream.php # Plugin entry point
``` ```
## Releases
Release packages are automatically built via Gitea Actions when a version tag is pushed. Each release includes:
- Production-ready ZIP package with all dependencies
- SHA256 checksum for verification
- Changelog notes extracted from CHANGELOG.md
Download releases from: <https://src.bundespruefstelle.ch/magdev/wp-fedistream/releases>
## Contributing ## Contributing
This project is in early development. Contributions, bug reports, and feature requests are welcome. This project is in early development. Contributions, bug reports, and feature requests are welcome.

View File

@@ -66,7 +66,7 @@ After activation, configure FediStream through **FediStream > Settings**.
### General Settings ### General Settings
| Setting | Description | Default | | Setting | Description | Default |
|---------|-------------|---------| | --------- | ------------- | --------- |
| ActivityPub Integration | Enable Fediverse features for sharing releases | Enabled | | ActivityPub Integration | Enable Fediverse features for sharing releases | Enabled |
| WooCommerce Integration | Enable selling music (requires WooCommerce) | Disabled | | WooCommerce Integration | Enable selling music (requires WooCommerce) | Disabled |
| Max Upload Size | Maximum audio file size in MB | 50 MB | | Max Upload Size | Maximum audio file size in MB | 50 MB |
@@ -110,7 +110,7 @@ Artists represent musicians, bands, duos, or collectives. Each artist can have t
### Artist Types ### Artist Types
| Type | Description | | Type | Description |
|------|-------------| | ------ | ------------- |
| Solo | Individual musician | | Solo | Individual musician |
| Band | Group of musicians | | Band | Group of musicians |
| Duo | Two-person musical act | | Duo | Two-person musical act |
@@ -141,7 +141,7 @@ Albums organize your music into releases. They can be full albums, EPs, singles,
### Album Types ### Album Types
| Type | Description | Typical Track Count | | Type | Description | Typical Track Count |
|------|-------------|---------------------| | ------ | ------------- | --------------------- |
| Album | Full-length release | 8-15 tracks | | Album | Full-length release | 8-15 tracks |
| EP | Extended Play | 4-6 tracks | | EP | Extended Play | 4-6 tracks |
| Single | Single track release | 1-3 tracks | | Single | Single track release | 1-3 tracks |
@@ -178,7 +178,7 @@ Tracks are the individual songs or audio files in your library.
### Audio File Guidelines ### Audio File Guidelines
| Format | Quality | Recommended For | | Format | Quality | Recommended For |
|--------|---------|-----------------| | -------- | --------- | ----------------- |
| MP3 | 320kbps | Web streaming | | MP3 | 320kbps | Web streaming |
| FLAC | Lossless | Downloads, archival | | FLAC | Lossless | Downloads, archival |
| WAV | Uncompressed | Master files | | WAV | Uncompressed | Master files |
@@ -328,7 +328,7 @@ FediStream includes widgets for your sidebar or widget areas.
### Available Widgets ### Available Widgets
| Widget | Description | | Widget | Description |
|--------|-------------| | -------- | ------------- |
| Recent Releases | Displays latest album releases | | Recent Releases | Displays latest album releases |
| Popular Tracks | Shows most-played tracks | | Popular Tracks | Shows most-played tracks |
| Featured Artist | Highlights a specific artist | | Featured Artist | Highlights a specific artist |
@@ -348,7 +348,7 @@ FediStream includes widgets for your sidebar or widget areas.
FediStream integrates with the Fediverse through ActivityPub, allowing your artists to be followed from Mastodon, Pixelfed, and other platforms. FediStream integrates with the Fediverse through ActivityPub, allowing your artists to be followed from Mastodon, Pixelfed, and other platforms.
### Requirements ### Requirements for ActivityPub integration
Install and activate the [ActivityPub plugin](https://wordpress.org/plugins/activitypub/) from WordPress.org. Install and activate the [ActivityPub plugin](https://wordpress.org/plugins/activitypub/) from WordPress.org.
@@ -370,7 +370,7 @@ https://your-site.com/.well-known/webfinger?resource=acct:artist-name@your-site.
### Activity Types ### Activity Types
| Activity | When Sent | | Activity | When Sent |
|----------|-----------| | ---------- | ----------- |
| Create | New album or track published | | Create | New album or track published |
| Update | Album or track updated | | Update | Album or track updated |
| Delete | Content removed | | Delete | Content removed |
@@ -386,7 +386,7 @@ Artist followers are displayed on the artist's admin page under the Followers me
Sell your music directly through your WordPress site using WooCommerce. Sell your music directly through your WordPress site using WooCommerce.
### Requirements ### Requirements for WooCommerce Integration
- WooCommerce 10.0 or higher installed and activated - WooCommerce 10.0 or higher installed and activated
- WooCommerce integration enabled in FediStream settings - WooCommerce integration enabled in FediStream settings
@@ -409,7 +409,7 @@ Sell your music directly through your WordPress site using WooCommerce.
### Pricing Types ### Pricing Types
| Type | Description | | Type | Description |
|------|-------------| | ------ | ------------- |
| Fixed | Standard fixed price | | Fixed | Standard fixed price |
| Pay What You Want (PWYW) | Customer chooses price above minimum | | Pay What You Want (PWYW) | Customer chooses price above minimum |
| Name Your Price (NYP) | Customer sets any price (including free) | | Name Your Price (NYP) | Customer sets any price (including free) |
@@ -517,7 +517,7 @@ Notifications appear in the admin bar and can be configured for email delivery.
### URL Slugs ### URL Slugs
| Content Type | URL Pattern | | Content Type | URL Pattern |
|--------------|-------------| | -------------- | ------------- |
| Artists | `/artists/{slug}/` | | Artists | `/artists/{slug}/` |
| Albums | `/albums/{slug}/` | | Albums | `/albums/{slug}/` |
| Tracks | `/tracks/{slug}/` | | Tracks | `/tracks/{slug}/` |
@@ -528,14 +528,14 @@ Notifications appear in the admin bar and can be configured for email delivery.
### User Roles ### User Roles
| Role | Capabilities | | Role | Capabilities |
|------|--------------| | ------ | -------------- |
| FediStream Artist | Manage own content, upload files, view own stats | | FediStream Artist | Manage own content, upload files, view own stats |
| FediStream Label | Manage all content, manage taxonomies, view all stats | | FediStream Label | Manage all content, manage taxonomies, view all stats |
### Keyboard Shortcuts (Player) ### Keyboard Shortcuts (Player)
| Key | Action | | Key | Action |
|-----|--------| | ----- | -------- |
| Space | Play/Pause | | Space | Play/Pause |
| Left Arrow | Previous track | | Left Arrow | Previous track |
| Right Arrow | Next track | | Right Arrow | Next track |

View File

@@ -4,4 +4,122 @@
* @package WP_FediStream * @package WP_FediStream
*/ */
/* Admin styles will be added here */ /* Settings page tabs */
.nav-tab-wrapper + .fedistream-settings-content {
margin-top: -1px;
}
.fedistream-settings-content {
background: #fff;
border: 1px solid #c3c4c7;
border-top: none;
padding: 20px;
}
/* Active tab styling */
.wrap .nav-tab-wrapper .nav-tab-active {
background: #fff;
border-bottom-color: #fff;
}
/* License status banner */
.fedistream-license-status {
margin-bottom: 20px;
}
.fedistream-license-status .dashicons {
color: inherit;
}
.fedistream-license-status.notice-success .dashicons {
color: #00a32a;
}
.fedistream-license-status.notice-error .dashicons {
color: #d63638;
}
.fedistream-license-status.notice-warning .dashicons {
color: #dba617;
}
.fedistream-license-status.notice-info .dashicons {
color: #72aee6;
}
/* License form buttons */
#fedistream-license-form .button .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 1.3;
}
/* License message display */
#fedistream-license-message {
padding: 10px 15px;
}
#fedistream-license-message p {
margin: 0;
}
/* Dashboard stats grid */
.fedistream-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin: 20px 0;
}
.fedistream-stat-box {
background: #fff;
padding: 20px;
border: 1px solid #ccd0d4;
border-radius: 4px;
}
.fedistream-stat-box h3 {
margin: 0 0 10px;
}
.fedistream-stat-box p {
font-size: 2em;
margin: 0;
color: #2271b1;
}
/* Quick actions */
.fedistream-quick-actions {
background: #fff;
padding: 20px;
border: 1px solid #ccd0d4;
border-radius: 4px;
margin: 20px 0;
}
/* Info box */
.fedistream-info {
background: #fff;
padding: 20px;
border: 1px solid #ccd0d4;
border-radius: 4px;
}
/* Responsive adjustments */
@media screen and (max-width: 782px) {
.fedistream-stats {
grid-template-columns: repeat(2, 1fr);
}
#fedistream-license-form .button {
display: block;
margin: 10px 0 0 0 !important;
}
}
@media screen and (max-width: 480px) {
.fedistream-stats {
grid-template-columns: 1fr;
}
}

View File

@@ -8,7 +8,115 @@
'use strict'; 'use strict';
$(document).ready(function() { $(document).ready(function() {
// Admin scripts will be added here // License validation functionality
initLicenseValidation();
}); });
/**
* Initialize license validation AJAX handlers.
*/
function initLicenseValidation() {
var $validateBtn = $('#fedistream-validate-license');
var $activateBtn = $('#fedistream-activate-license');
var $spinner = $('#fedistream-license-spinner');
var $message = $('#fedistream-license-message');
if (!$validateBtn.length) {
return;
}
// Validate license button
$validateBtn.on('click', function(e) {
e.preventDefault();
performLicenseAction('fedistream_validate_license', 'Validating...');
});
// Activate license button
$activateBtn.on('click', function(e) {
e.preventDefault();
performLicenseAction('fedistream_activate_license', 'Activating...');
});
/**
* Perform license AJAX action.
*
* @param {string} action AJAX action name.
* @param {string} loadingText Loading button text.
*/
function performLicenseAction(action, loadingText) {
var originalText = $validateBtn.text();
// Show loading state
$spinner.addClass('is-active');
$validateBtn.prop('disabled', true);
$activateBtn.prop('disabled', true);
$message.hide();
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: action,
nonce: fedistreamLicenseNonce
},
success: function(response) {
$spinner.removeClass('is-active');
$validateBtn.prop('disabled', false);
$activateBtn.prop('disabled', false);
if (response.success) {
showMessage('success', response.data.message);
// Reload page to show updated status
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
showMessage('error', response.data.message || 'An error occurred.');
}
},
error: function(xhr, status, error) {
$spinner.removeClass('is-active');
$validateBtn.prop('disabled', false);
$activateBtn.prop('disabled', false);
showMessage('error', 'Request failed. Please try again.');
}
});
}
/**
* Show a message to the user.
*
* @param {string} type Message type: 'success', 'error', 'warning', 'info'.
* @param {string} text Message text.
*/
function showMessage(type, text) {
var classMap = {
'success': 'notice-success',
'error': 'notice-error',
'warning': 'notice-warning',
'info': 'notice-info'
};
var noticeClass = classMap[type] || 'notice-info';
$message
.removeClass('notice-success notice-error notice-warning notice-info')
.addClass('notice ' + noticeClass)
.html('<p>' + escapeHtml(text) + '</p>')
.show();
}
/**
* Escape HTML entities.
*
* @param {string} text Text to escape.
* @return {string} Escaped text.
*/
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
})(jQuery); })(jQuery);

View File

@@ -14,9 +14,15 @@
"support": { "support": {
"issues": "https://src.bundespruefstelle.ch/magdev/wp-fedistream/issues" "issues": "https://src.bundespruefstelle.ch/magdev/wp-fedistream/issues"
}, },
"repositories": [
{
"type": "path",
"url": "lib/wc-licensed-product-client"
}
],
"require": { "require": {
"php": ">=8.3", "php": ">=8.3",
"twig/twig": "^3.0" "magdev/wc-licensed-product-client": "^0.2"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.0", "phpunit/phpunit": "^10.0",

717
composer.lock generated
View File

@@ -4,8 +4,314 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c8fb50541e5730c8ad92b76392765aca", "content-hash": "19d851431c9b602615e115746e3cbd9d",
"packages": [ "packages": [
{
"name": "magdev/wc-licensed-product-client",
"version": "v0.2.2",
"dist": {
"type": "path",
"url": "lib/wc-licensed-product-client",
"reference": "f9281ec5fb23bf1993ab0240e0347c835009a10f"
},
"require": {
"php": "^8.3",
"psr/cache": "^3.0",
"psr/http-client": "^1.0",
"psr/log": "^3.0",
"symfony/http-client": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Magdev\\WcLicensedProductClient\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Magdev\\WcLicensedProductClient\\Tests\\": "tests/"
}
},
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Marco Graetsch",
"email": "magdev3.0@gmail.com",
"homepage": "https://src.bundespruefstelle.ch/magdev"
}
],
"description": "Client library for WooCommerce Licensed Product Plugin - Activate, validate and check the status of licenses via REST API",
"homepage": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client",
"support": {
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
},
"transport-options": {
"relative": true
}
},
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/container",
"version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/container.git",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
"shasum": ""
},
"require": {
"php": ">=7.4.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Container\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common Container Interface (PHP FIG PSR-11)",
"homepage": "https://github.com/php-fig/container",
"keywords": [
"PSR-11",
"container",
"container-interface",
"container-interop",
"psr"
],
"support": {
"issues": "https://github.com/php-fig/container/issues",
"source": "https://github.com/php-fig/container/tree/2.0.2"
},
"time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-client.git",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP clients",
"homepage": "https://github.com/php-fig/http-client",
"keywords": [
"http",
"http-client",
"psr",
"psr-18"
],
"support": {
"source": "https://github.com/php-fig/http-client"
},
"time": "2023-09-23T14:17:50+00:00"
},
{
"name": "psr/http-message",
"version": "2.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"support": {
"source": "https://github.com/php-fig/http-message/tree/2.0"
},
"time": "2023-04-04T09:54:51+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
"version": "v3.6.0", "version": "v3.6.0",
@@ -74,126 +380,63 @@
"time": "2024-09-25T14:21:43+00:00" "time": "2024-09-25T14:21:43+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/http-client",
"version": "v1.33.0", "version": "v7.4.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git", "url": "https://github.com/symfony/http-client.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=7.2" "php": ">=8.2",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/polyfill-php83": "^1.29",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<2.5",
"amphp/socket": "<1.1",
"php-http/discovery": "<1.15",
"symfony/http-foundation": "<6.4"
}, },
"provide": { "provide": {
"ext-ctype": "*" "php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
}, },
"suggest": { "require-dev": {
"ext-ctype": "For best performance" "amphp/http-client": "^4.2.1|^5.0",
"amphp/http-tunnel": "^1.0|^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/amphp-http-client-meta": "^1.0|^2.0",
"symfony/cache": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0"
}, },
"type": "library", "type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": { "autoload": {
"files": [
"bootstrap.php"
],
"psr-4": { "psr-4": {
"Symfony\\Polyfill\\Ctype\\": "" "Symfony\\Component\\HttpClient\\": ""
}
}, },
"notification-url": "https://packagist.org/downloads/", "exclude-from-classmap": [
"license": [ "/Tests/"
"MIT" ]
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
},
"suggest": {
"ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Mbstring\\": ""
}
}, },
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
@@ -209,17 +452,13 @@
"homepage": "https://symfony.com/contributors" "homepage": "https://symfony.com/contributors"
} }
], ],
"description": "Symfony polyfill for the Mbstring extension", "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"keywords": [ "keywords": [
"compatibility", "http"
"mbstring",
"polyfill",
"portable",
"shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" "source": "https://github.com/symfony/http-client/tree/v7.4.5"
}, },
"funding": [ "funding": [
{ {
@@ -239,86 +478,252 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-12-23T08:48:59+00:00" "time": "2026-01-27T16:16:02+00:00"
}, },
{ {
"name": "twig/twig", "name": "symfony/http-client-contracts",
"version": "v3.23.0", "version": "v3.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/symfony/http-client-contracts.git",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" "reference": "75d7043853a42837e68111812f4d964b01e5101c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", "reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.1.0", "php": ">=8.1"
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
}, },
"type": "library", "type": "library",
"autoload": { "extra": {
"files": [ "thanks": {
"src/Resources/core.php", "url": "https://github.com/symfony/contracts",
"src/Resources/debug.php", "name": "symfony/contracts"
"src/Resources/escaper.php", },
"src/Resources/string_loader.php" "branch-alias": {
], "dev-main": "3.6-dev"
"psr-4": {
"Twig\\": "src/"
} }
}, },
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/", "notification-url": "https://packagist.org/downloads/",
"license": [ "license": [
"BSD-3-Clause" "MIT"
], ],
"authors": [ "authors": [
{ {
"name": "Fabien Potencier", "name": "Nicolas Grekas",
"email": "fabien@symfony.com", "email": "p@tchwork.com"
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}, },
{ {
"name": "Twig Team", "name": "Symfony Community",
"role": "Contributors" "homepage": "https://symfony.com/contributors"
},
{
"name": "Armin Ronacher",
"email": "armin.ronacher@active-4.com",
"role": "Project Founder"
} }
], ],
"description": "Twig, the flexible, fast, and secure template language for PHP", "description": "Generic abstractions related to HTTP clients",
"homepage": "https://twig.symfony.com", "homepage": "https://symfony.com",
"keywords": [ "keywords": [
"templating" "abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
], ],
"support": { "support": {
"issues": "https://github.com/twigphp/Twig/issues", "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
"source": "https://github.com/twigphp/Twig/tree/v3.23.0"
}, },
"funding": [ "funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{ {
"url": "https://github.com/fabpot", "url": "https://github.com/fabpot",
"type": "github" "type": "github"
}, },
{ {
"url": "https://tidelift.com/funding/github/packagist/twig/twig", "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-23T21:00:41+00:00" "time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/polyfill-php83",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
"reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php83\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-08T02:45:35+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/container": "^1.1|^2.0",
"symfony/deprecation-contracts": "^2.5|^3"
},
"conflict": {
"ext-psr": "<1.1|>=2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\Service\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to writing services",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-15T11:30:57+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [

View File

@@ -7,6 +7,8 @@
namespace WP_FediStream\Frontend; namespace WP_FediStream\Frontend;
use WP_FediStream\License\Manager as LicenseManager;
// Prevent direct file access. // Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) { if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
@@ -36,6 +38,11 @@ class Ajax {
* @return void * @return void
*/ */
public function get_track(): void { public function get_track(): void {
// Check license.
if ( ! LicenseManager::is_license_valid() ) {
wp_send_json_error( array( 'message' => __( 'This feature requires a valid license.', 'wp-fedistream' ) ) );
}
// Verify nonce. // Verify nonce.
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) { if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) ); wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) );
@@ -125,6 +132,11 @@ class Ajax {
* @return void * @return void
*/ */
public function record_play(): void { public function record_play(): void {
// Check license.
if ( ! LicenseManager::is_license_valid() ) {
wp_send_json_error( array( 'message' => __( 'This feature requires a valid license.', 'wp-fedistream' ) ) );
}
// Verify nonce. // Verify nonce.
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) { if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), 'wp-fedistream-nonce' ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) ); wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'wp-fedistream' ) ) );

View File

@@ -27,13 +27,35 @@ class Shortcodes {
private Plugin $plugin; private Plugin $plugin;
/** /**
* Constructor. * Whether running in unlicensed mode.
*
* @var bool
*/ */
public function __construct() { private bool $unlicensed_mode = false;
/**
* Constructor.
*
* @param bool $unlicensed_mode Whether to run in unlicensed mode.
*/
public function __construct( bool $unlicensed_mode = false ) {
$this->plugin = Plugin::get_instance(); $this->plugin = Plugin::get_instance();
$this->unlicensed_mode = $unlicensed_mode;
$this->register_shortcodes(); $this->register_shortcodes();
} }
/**
* Get the unlicensed message HTML.
*
* @return string
*/
private function get_unlicensed_message(): string {
return '<div class="fedistream-unlicensed-notice" style="padding: 20px; background: #f0f0f1; border-left: 4px solid #dba617; margin: 10px 0;">'
. '<p style="margin: 0; color: #50575e;">'
. esc_html__( 'This content requires a valid FediStream license.', 'wp-fedistream' )
. '</p></div>';
}
/** /**
* Register all shortcodes. * Register all shortcodes.
* *
@@ -59,6 +81,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_artist( array $atts ): string { public function render_artist( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -73,6 +98,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_artist' ); $post = $this->get_post( $atts, 'fedistream_artist' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -97,6 +123,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_album( array $atts ): string { public function render_album( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -110,6 +139,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_album' ); $post = $this->get_post( $atts, 'fedistream_album' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -133,6 +163,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_track( array $atts ): string { public function render_track( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -146,6 +179,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_track' ); $post = $this->get_post( $atts, 'fedistream_track' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -169,6 +203,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_playlist( array $atts ): string { public function render_playlist( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'id' => 0, 'id' => 0,
@@ -182,6 +219,7 @@ class Shortcodes {
$post = $this->get_post( $atts, 'fedistream_playlist' ); $post = $this->get_post( $atts, 'fedistream_playlist' );
if ( ! $post ) { if ( ! $post ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -205,6 +243,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_latest_releases( array $atts ): string { public function render_latest_releases( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'count' => 6, 'count' => 6,
@@ -270,6 +311,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_popular_tracks( array $atts ): string { public function render_popular_tracks( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'count' => 10, 'count' => 10,
@@ -337,6 +381,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_artists_grid( array $atts ): string { public function render_artists_grid( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'count' => 12, 'count' => 12,
@@ -404,6 +451,9 @@ class Shortcodes {
* @return string * @return string
*/ */
public function render_player( array $atts ): string { public function render_player( array $atts ): string {
// Enter shortcode context to prevent recursive shortcode processing during data loading.
TemplateLoader::enter_shortcode_context();
$atts = shortcode_atts( $atts = shortcode_atts(
array( array(
'track' => 0, 'track' => 0,
@@ -449,6 +499,7 @@ class Shortcodes {
} }
if ( empty( $tracks ) ) { if ( empty( $tracks ) ) {
TemplateLoader::exit_shortcode_context();
return ''; return '';
} }
@@ -501,13 +552,31 @@ class Shortcodes {
* @return string * @return string
*/ */
private function render_template( string $template, array $context ): string { private function render_template( string $template, array $context ): string {
// Block shortcode rendering while loading page template to prevent recursion.
// This catches any shortcodes triggered by theme header/footer, widgets, etc.
if ( TemplateLoader::is_loading_page_template() ) {
return '<!-- FediStream: shortcode blocked during page template loading -->';
}
// Check for unlicensed mode.
if ( $this->unlicensed_mode ) {
return $this->get_unlicensed_message();
}
// Enter shortcode context to prevent recursive shortcode processing.
TemplateLoader::enter_shortcode_context();
try { try {
return $this->plugin->render( $template, $context ); $result = $this->plugin->render( $template, $context );
} catch ( \Exception $e ) { } catch ( \Exception $e ) {
TemplateLoader::exit_shortcode_context();
if ( WP_DEBUG ) { if ( WP_DEBUG ) {
return '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>'; return '<p class="fedistream-error">' . esc_html( $e->getMessage() ) . '</p>';
} }
return ''; return '';
} }
TemplateLoader::exit_shortcode_context();
return $result;
} }
} }

View File

@@ -21,6 +21,96 @@ if ( ! defined( 'ABSPATH' ) ) {
*/ */
class TemplateLoader { class TemplateLoader {
/**
* Recursion depth for get_post_data calls.
*
* @var int
*/
private static int $recursion_depth = 0;
/**
* Maximum allowed recursion depth.
*
* @var int
*/
private const MAX_RECURSION_DEPTH = 3;
/**
* Shortcode rendering context depth counter.
* When > 0, the_content filter is skipped to prevent recursive shortcode processing.
* Using a counter instead of boolean to handle nested shortcodes properly.
*
* @var int
*/
private static int $shortcode_context_depth = 0;
/**
* Flag indicating we're currently loading a FediStream page template.
* This completely blocks any nested FediStream shortcode rendering.
*
* @var bool
*/
private static bool $loading_page_template = false;
/**
* Enter page template loading mode.
* This blocks ALL shortcode rendering during page template loading.
*
* @return void
*/
public static function enter_page_template_loading(): void {
self::$loading_page_template = true;
}
/**
* Exit page template loading mode.
*
* @return void
*/
public static function exit_page_template_loading(): void {
self::$loading_page_template = false;
}
/**
* Check if we're loading a page template.
*
* @return bool
*/
public static function is_loading_page_template(): bool {
return self::$loading_page_template;
}
/**
* Enter shortcode rendering context.
* Call this before rendering shortcode content to prevent recursive shortcode processing.
*
* @return void
*/
public static function enter_shortcode_context(): void {
++self::$shortcode_context_depth;
}
/**
* Exit shortcode rendering context.
* Call this after shortcode rendering is complete.
*
* @return void
*/
public static function exit_shortcode_context(): void {
if ( self::$shortcode_context_depth > 0 ) {
--self::$shortcode_context_depth;
}
}
/**
* Check if we're in a shortcode rendering context.
*
* @return bool
*/
public static function is_in_shortcode_context(): bool {
return self::$shortcode_context_depth > 0;
}
/** /**
* Constructor. * Constructor.
*/ */
@@ -192,33 +282,68 @@ class TemplateLoader {
* Get post data for template. * Get post data for template.
* *
* @param \WP_Post $post Post object. * @param \WP_Post $post Post object.
* @param bool $skip_nested Whether to skip loading nested items (albums, tracks, etc.).
* @return array Post data. * @return array Post data.
*/ */
public static function get_post_data( \WP_Post $post ): array { public static function get_post_data( \WP_Post $post, bool $skip_nested = false ): array {
// Track recursion to prevent infinite loops from shortcodes in content.
++self::$recursion_depth;
// Skip the_content filter if:
// 1. We're in a shortcode context (prevents recursive shortcode processing)
// 2. We're at depth > 1 (nested data loading)
// 3. We're loading a page template
$skip_content_filter = self::$shortcode_context_depth > 0
|| self::$recursion_depth > 1
|| self::$loading_page_template;
// When skipping content filter, use raw excerpt to avoid get_the_excerpt()
// triggering the_content filter internally when generating auto-excerpts.
if ( $skip_content_filter ) {
$excerpt = $post->post_excerpt;
if ( empty( $excerpt ) ) {
// Generate a simple excerpt without triggering the_content filter.
$excerpt = wp_trim_words( wp_strip_all_tags( $post->post_content ), 55, '&hellip;' );
}
} else {
$excerpt = get_the_excerpt( $post );
}
// When skipping content filter, strip shortcodes to prevent them from
// being processed by anything else that might call do_shortcode on the output.
if ( $skip_content_filter ) {
$content = strip_shortcodes( $post->post_content );
$content = wp_kses_post( $content );
} else {
$content = apply_filters( 'the_content', $post->post_content );
}
$data = array( $data = array(
'id' => $post->ID, 'id' => $post->ID,
'title' => get_the_title( $post ), 'title' => get_the_title( $post ),
'content' => apply_filters( 'the_content', $post->post_content ), 'content' => $content,
'excerpt' => get_the_excerpt( $post ), 'excerpt' => $excerpt,
'permalink' => get_permalink( $post ), 'permalink' => get_permalink( $post ),
'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ), 'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ),
'date' => get_the_date( '', $post ), 'date' => get_the_date( '', $post ),
'author' => get_the_author_meta( 'display_name', $post->post_author ), 'author' => get_the_author_meta( 'display_name', $post->post_author ),
); );
// Add post type specific data. // Add post type specific data (skip nested items if at max depth).
$load_nested = ! $skip_nested && self::$recursion_depth < self::MAX_RECURSION_DEPTH;
switch ( $post->post_type ) { switch ( $post->post_type ) {
case 'fedistream_artist': case 'fedistream_artist':
$data = array_merge( $data, self::get_artist_data( $post->ID ) ); $data = array_merge( $data, self::get_artist_data( $post->ID, $load_nested ) );
break; break;
case 'fedistream_album': case 'fedistream_album':
$data = array_merge( $data, self::get_album_data( $post->ID ) ); $data = array_merge( $data, self::get_album_data( $post->ID, $load_nested ) );
break; break;
case 'fedistream_track': case 'fedistream_track':
$data = array_merge( $data, self::get_track_data( $post->ID ) ); $data = array_merge( $data, self::get_track_data( $post->ID ) );
break; break;
case 'fedistream_playlist': case 'fedistream_playlist':
$data = array_merge( $data, self::get_playlist_data( $post->ID ) ); $data = array_merge( $data, self::get_playlist_data( $post->ID, $load_nested ) );
break; break;
} }
@@ -226,16 +351,23 @@ class TemplateLoader {
$data['genres'] = self::get_terms( $post->ID, 'fedistream_genre' ); $data['genres'] = self::get_terms( $post->ID, 'fedistream_genre' );
$data['moods'] = self::get_terms( $post->ID, 'fedistream_mood' ); $data['moods'] = self::get_terms( $post->ID, 'fedistream_mood' );
--self::$recursion_depth;
return $data; return $data;
} }
/** /**
* Get artist-specific data. * Get artist-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @param bool $load_nested Whether to load nested albums.
* @return array Artist data. * @return array Artist data.
*/ */
private static function get_artist_data( int $post_id ): array { public static function get_artist_data( int|\WP_Post $post_id, bool $load_nested = true ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
$type = get_post_meta( $post_id, '_fedistream_artist_type', true ) ?: 'solo'; $type = get_post_meta( $post_id, '_fedistream_artist_type', true ) ?: 'solo';
$types = array( $types = array(
'solo' => __( 'Solo Artist', 'wp-fedistream' ), 'solo' => __( 'Solo Artist', 'wp-fedistream' ),
@@ -244,7 +376,11 @@ class TemplateLoader {
'collective' => __( 'Collective', 'wp-fedistream' ), 'collective' => __( 'Collective', 'wp-fedistream' ),
); );
$albums = get_posts( $albums = array();
$album_count = 0;
if ( $load_nested ) {
$album_posts = get_posts(
array( array(
'post_type' => 'fedistream_album', 'post_type' => 'fedistream_album',
'posts_per_page' => -1, 'posts_per_page' => -1,
@@ -261,6 +397,27 @@ class TemplateLoader {
'order' => 'DESC', 'order' => 'DESC',
) )
); );
$album_count = count( $album_posts );
$albums = array_map(
function ( $album ) {
return self::get_post_data( $album, true ); // Skip further nesting.
},
$album_posts
);
} else {
// Just get the count without loading full data.
$album_count = (int) get_posts(
array(
'post_type' => 'fedistream_album',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_album_artist',
'meta_value' => $post_id,
'fields' => 'ids',
)
);
$album_count = is_array( $album_count ) ? count( $album_count ) : 0;
}
return array( return array(
'artist_type' => $type, 'artist_type' => $type,
@@ -270,18 +427,23 @@ class TemplateLoader {
'website' => get_post_meta( $post_id, '_fedistream_artist_website', true ), 'website' => get_post_meta( $post_id, '_fedistream_artist_website', true ),
'social_links' => get_post_meta( $post_id, '_fedistream_artist_social_links', true ) ?: array(), 'social_links' => get_post_meta( $post_id, '_fedistream_artist_social_links', true ) ?: array(),
'members' => get_post_meta( $post_id, '_fedistream_artist_members', true ) ?: array(), 'members' => get_post_meta( $post_id, '_fedistream_artist_members', true ) ?: array(),
'albums' => array_map( array( __CLASS__, 'get_post_data' ), $albums ), 'albums' => $albums,
'album_count' => count( $albums ), 'album_count' => $album_count,
); );
} }
/** /**
* Get album-specific data. * Get album-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @param bool $load_nested Whether to load nested tracks.
* @return array Album data. * @return array Album data.
*/ */
private static function get_album_data( int $post_id ): array { public static function get_album_data( int|\WP_Post $post_id, bool $load_nested = true ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
$type = get_post_meta( $post_id, '_fedistream_album_type', true ) ?: 'album'; $type = get_post_meta( $post_id, '_fedistream_album_type', true ) ?: 'album';
$types = array( $types = array(
'album' => __( 'Album', 'wp-fedistream' ), 'album' => __( 'Album', 'wp-fedistream' ),
@@ -293,7 +455,11 @@ class TemplateLoader {
); );
$artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true ); $artist_id = get_post_meta( $post_id, '_fedistream_album_artist', true );
$tracks = get_posts( $tracks = array();
$total_tracks = 0;
if ( $load_nested ) {
$track_posts = get_posts(
array( array(
'post_type' => 'fedistream_track', 'post_type' => 'fedistream_track',
'posts_per_page' => -1, 'posts_per_page' => -1,
@@ -311,6 +477,27 @@ class TemplateLoader {
'order' => 'ASC', 'order' => 'ASC',
) )
); );
$total_tracks = count( $track_posts );
$tracks = array_map(
function ( $track ) {
return self::get_post_data( $track, true ); // Skip further nesting.
},
$track_posts
);
} else {
// Just get the count without loading full data.
$track_ids = get_posts(
array(
'post_type' => 'fedistream_track',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_key' => '_fedistream_track_album',
'meta_value' => $post_id,
'fields' => 'ids',
)
);
$total_tracks = is_array( $track_ids ) ? count( $track_ids ) : 0;
}
return array( return array(
'album_type' => $type, 'album_type' => $type,
@@ -322,19 +509,23 @@ class TemplateLoader {
'artist_url' => $artist_id ? get_permalink( $artist_id ) : '', 'artist_url' => $artist_id ? get_permalink( $artist_id ) : '',
'upc' => get_post_meta( $post_id, '_fedistream_album_upc', true ), 'upc' => get_post_meta( $post_id, '_fedistream_album_upc', true ),
'catalog_number' => get_post_meta( $post_id, '_fedistream_album_catalog_number', true ), 'catalog_number' => get_post_meta( $post_id, '_fedistream_album_catalog_number', true ),
'total_tracks' => count( $tracks ), 'total_tracks' => $total_tracks,
'total_duration' => (int) get_post_meta( $post_id, '_fedistream_album_total_duration', true ), 'total_duration' => (int) get_post_meta( $post_id, '_fedistream_album_total_duration', true ),
'tracks' => array_map( array( __CLASS__, 'get_post_data' ), $tracks ), 'tracks' => $tracks,
); );
} }
/** /**
* Get track-specific data. * Get track-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @return array Track data. * @return array Track data.
*/ */
private static function get_track_data( int $post_id ): array { public static function get_track_data( int|\WP_Post $post_id ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
$album_id = get_post_meta( $post_id, '_fedistream_track_album', true ); $album_id = get_post_meta( $post_id, '_fedistream_track_album', true );
$audio_file = get_post_meta( $post_id, '_fedistream_track_audio_file', true ); $audio_file = get_post_meta( $post_id, '_fedistream_track_audio_file', true );
$artists = get_post_meta( $post_id, '_fedistream_track_artists', true ) ?: array(); $artists = get_post_meta( $post_id, '_fedistream_track_artists', true ) ?: array();
@@ -374,16 +565,21 @@ class TemplateLoader {
/** /**
* Get playlist-specific data. * Get playlist-specific data.
* *
* @param int $post_id Post ID. * @param int|\WP_Post $post_id Post ID or WP_Post object.
* @param bool $load_nested Whether to load nested tracks.
* @return array Playlist data. * @return array Playlist data.
*/ */
private static function get_playlist_data( int $post_id ): array { public static function get_playlist_data( int|\WP_Post $post_id, bool $load_nested = true ): array {
// Support both post ID and WP_Post object.
if ( $post_id instanceof \WP_Post ) {
$post_id = $post_id->ID;
}
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'fedistream_playlist_tracks'; $table = $wpdb->prefix . 'fedistream_playlist_tracks';
$duration = (int) get_post_meta( $post_id, '_fedistream_playlist_total_duration', true ); $duration = (int) get_post_meta( $post_id, '_fedistream_playlist_total_duration', true );
// Get tracks. // Get track IDs.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$track_ids = $wpdb->get_col( $track_ids = $wpdb->get_col(
$wpdb->prepare( $wpdb->prepare(
@@ -393,10 +589,14 @@ class TemplateLoader {
); );
$tracks = array(); $tracks = array();
$track_count = count( $track_ids );
if ( $load_nested && ! empty( $track_ids ) ) {
foreach ( $track_ids as $track_id ) { foreach ( $track_ids as $track_id ) {
$track = get_post( $track_id ); $track = get_post( $track_id );
if ( $track && 'publish' === $track->post_status ) { if ( $track && 'publish' === $track->post_status ) {
$tracks[] = self::get_post_data( $track ); $tracks[] = self::get_post_data( $track, true ); // Skip further nesting.
}
} }
} }
@@ -404,7 +604,7 @@ class TemplateLoader {
'visibility' => get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public', 'visibility' => get_post_meta( $post_id, '_fedistream_playlist_visibility', true ) ?: 'public',
'collaborative' => (bool) get_post_meta( $post_id, '_fedistream_playlist_collaborative', true ), 'collaborative' => (bool) get_post_meta( $post_id, '_fedistream_playlist_collaborative', true ),
'federated' => (bool) get_post_meta( $post_id, '_fedistream_playlist_federated', true ), 'federated' => (bool) get_post_meta( $post_id, '_fedistream_playlist_federated', true ),
'track_count' => count( $tracks ), 'track_count' => $load_nested ? count( $tracks ) : $track_count,
'total_duration' => $duration, 'total_duration' => $duration,
'duration_formatted' => $duration >= 3600 'duration_formatted' => $duration >= 3600
? sprintf( '%d:%02d:%02d', floor( $duration / 3600 ), floor( ( $duration % 3600 ) / 60 ), $duration % 60 ) ? sprintf( '%d:%02d:%02d', floor( $duration / 3600 ), floor( ( $duration % 3600 ) / 60 ), $duration % 60 )

View File

@@ -1,6 +1,6 @@
<?php <?php
/** /**
* Template wrapper for FediStream Twig templates. * Template wrapper for FediStream PHP templates.
* *
* @package WP_FediStream * @package WP_FediStream
*/ */
@@ -13,6 +13,12 @@ if ( ! defined( 'ABSPATH' ) ) {
use WP_FediStream\Plugin; use WP_FediStream\Plugin;
use WP_FediStream\Frontend\TemplateLoader; use WP_FediStream\Frontend\TemplateLoader;
// Enter page template loading mode - this completely blocks nested FediStream rendering.
TemplateLoader::enter_page_template_loading();
// Also enter shortcode context to prevent recursive shortcode processing in post content.
TemplateLoader::enter_shortcode_context();
// Get template context. // Get template context.
$context = TemplateLoader::get_context(); $context = TemplateLoader::get_context();
@@ -54,7 +60,8 @@ get_header();
if ( $template_name ) { if ( $template_name ) {
try { try {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $plugin->render( $template_name, $context ); // Pass true for is_main_template to set the hard rendering lock.
echo $plugin->render( $template_name, $context, true );
} catch ( \Exception $e ) { } catch ( \Exception $e ) {
if ( WP_DEBUG ) { if ( WP_DEBUG ) {
echo '<div class="fedistream-error">'; echo '<div class="fedistream-error">';
@@ -75,4 +82,8 @@ get_header();
</main> </main>
<?php <?php
// Exit shortcode context and page template loading mode.
TemplateLoader::exit_shortcode_context();
TemplateLoader::exit_page_template_loading();
get_footer(); get_footer();

View File

@@ -347,9 +347,17 @@ class Installer {
$defaults = array( $defaults = array(
'wp_fedistream_enable_activitypub' => 1, 'wp_fedistream_enable_activitypub' => 1,
'wp_fedistream_enable_woocommerce' => 0, 'wp_fedistream_enable_woocommerce' => 0,
'wp_fedistream_enable_prometheus' => 0,
'wp_fedistream_audio_formats' => array( 'mp3', 'wav', 'flac', 'ogg' ), 'wp_fedistream_audio_formats' => array( 'mp3', 'wav', 'flac', 'ogg' ),
'wp_fedistream_max_upload_size' => 50, // MB 'wp_fedistream_max_upload_size' => 50, // MB
'wp_fedistream_default_license' => 'all-rights-reserved', 'wp_fedistream_default_license' => 'all-rights-reserved',
// License management options.
'wp_fedistream_license_key' => '',
'wp_fedistream_license_server_url' => '',
'wp_fedistream_license_server_secret' => '',
'wp_fedistream_license_status' => 'unchecked',
'wp_fedistream_license_data' => array(),
'wp_fedistream_license_last_check' => 0,
); );
foreach ( $defaults as $option => $value ) { foreach ( $defaults as $option => $value ) {

View File

@@ -0,0 +1,690 @@
<?php
/**
* License management class.
*
* Wraps the wc-licensed-product-client library for WordPress integration.
*
* @package WP_FediStream
*/
namespace WP_FediStream\License;
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Dto\LicenseState;
use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Magdev\WcLicensedProductClient\Exception\LicenseNotFoundException;
use Magdev\WcLicensedProductClient\Exception\LicenseExpiredException;
use Magdev\WcLicensedProductClient\Exception\LicenseInvalidException;
use Magdev\WcLicensedProductClient\Exception\LicenseRevokedException;
use Magdev\WcLicensedProductClient\Exception\LicenseInactiveException;
use Magdev\WcLicensedProductClient\Exception\DomainMismatchException;
use Magdev\WcLicensedProductClient\Exception\MaxActivationsReachedException;
use Magdev\WcLicensedProductClient\Exception\RateLimitExceededException;
use Magdev\WcLicensedProductClient\Security\SignatureException;
use Symfony\Component\HttpClient\HttpClient;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* License Manager class.
*
* Handles license validation, activation, and status checking.
*/
final class Manager {
/**
* Option names for license settings.
*/
public const OPTION_LICENSE_KEY = 'wp_fedistream_license_key';
public const OPTION_SERVER_URL = 'wp_fedistream_license_server_url';
public const OPTION_SERVER_SECRET = 'wp_fedistream_license_server_secret';
public const OPTION_LICENSE_STATUS = 'wp_fedistream_license_status';
public const OPTION_LICENSE_DATA = 'wp_fedistream_license_data';
public const OPTION_LAST_CHECK = 'wp_fedistream_license_last_check';
/**
* Transient name for caching license validation.
*/
private const TRANSIENT_LICENSE_CHECK = 'wp_fedistream_license_check';
/**
* Cache TTL in seconds (24 hours).
*/
private const CACHE_TTL = 86400;
/**
* Singleton instance.
*
* @var Manager|null
*/
private static ?Manager $instance = null;
/**
* License client instance.
*
* @var SecureLicenseClient|null
*/
private ?SecureLicenseClient $client = null;
/**
* Get singleton instance.
*
* @return Manager
*/
public static function get_instance(): Manager {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor.
*/
private function __construct() {
$this->init_hooks();
}
/**
* Initialize WordPress hooks.
*
* @return void
*/
private function init_hooks(): void {
add_action( 'wp_ajax_fedistream_validate_license', array( $this, 'ajax_validate_license' ) );
add_action( 'wp_ajax_fedistream_activate_license', array( $this, 'ajax_activate_license' ) );
add_action( 'wp_ajax_fedistream_deactivate_license', array( $this, 'ajax_deactivate_license' ) );
add_action( 'wp_ajax_fedistream_check_license_status', array( $this, 'ajax_check_status' ) );
}
/**
* Initialize the license client.
*
* @return bool True if client was initialized successfully.
*/
private function init_client(): bool {
if ( null !== $this->client ) {
return true;
}
$server_url = self::get_server_url();
$server_secret = self::get_server_secret();
if ( empty( $server_url ) || empty( $server_secret ) ) {
return false;
}
try {
$this->client = new SecureLicenseClient(
httpClient: HttpClient::create(),
baseUrl: $server_url,
serverSecret: $server_secret,
);
return true;
} catch ( \Throwable $e ) {
return false;
}
}
/**
* Validate the current license.
*
* @return array{success: bool, message: string, data?: array}
*/
public function validate(): array {
if ( ! $this->init_client() ) {
return array(
'success' => false,
'message' => __( 'License server configuration is incomplete.', 'wp-fedistream' ),
);
}
$license_key = self::get_license_key();
if ( empty( $license_key ) ) {
return array(
'success' => false,
'message' => __( 'No license key provided.', 'wp-fedistream' ),
);
}
$domain = wp_parse_url( home_url(), PHP_URL_HOST );
try {
$result = $this->client->validate( $license_key, $domain );
// Update cached status.
$this->update_cached_status( 'valid', array(
'product_id' => $result->productId,
'expires_at' => $result->expiresAt?->format( 'c' ),
'version_id' => $result->versionId,
) );
return array(
'success' => true,
'message' => __( 'License validated successfully.', 'wp-fedistream' ),
'data' => array(
'status' => 'valid',
'product_id' => $result->productId,
'expires_at' => $result->expiresAt?->format( 'Y-m-d' ),
'lifetime' => $result->isLifetime(),
),
);
} catch ( LicenseNotFoundException $e ) {
$this->update_cached_status( 'invalid' );
return array(
'success' => false,
'message' => __( 'License key not found. Please check your license key.', 'wp-fedistream' ),
);
} catch ( LicenseExpiredException $e ) {
$this->update_cached_status( 'expired' );
return array(
'success' => false,
'message' => __( 'Your license has expired. Please renew to continue.', 'wp-fedistream' ),
);
} catch ( LicenseRevokedException $e ) {
$this->update_cached_status( 'revoked' );
return array(
'success' => false,
'message' => __( 'Your license has been revoked.', 'wp-fedistream' ),
);
} catch ( LicenseInactiveException $e ) {
$this->update_cached_status( 'inactive' );
return array(
'success' => false,
'message' => __( 'License is inactive. Please activate it first.', 'wp-fedistream' ),
);
} catch ( DomainMismatchException $e ) {
$this->update_cached_status( 'invalid' );
return array(
'success' => false,
'message' => __( 'This license is not activated for this domain.', 'wp-fedistream' ),
);
} catch ( SignatureException $e ) {
return array(
'success' => false,
'message' => __( 'License verification failed. Please check your server secret.', 'wp-fedistream' ),
);
} catch ( RateLimitExceededException $e ) {
return array(
'success' => false,
'message' => __( 'Too many requests. Please try again later.', 'wp-fedistream' ),
);
} catch ( LicenseException $e ) {
return array(
'success' => false,
'message' => sprintf(
/* translators: %s: Error message */
__( 'License validation failed: %s', 'wp-fedistream' ),
$e->getMessage()
),
);
} catch ( \Throwable $e ) {
return array(
'success' => false,
'message' => __( 'Unable to verify license. Please try again later.', 'wp-fedistream' ),
);
}
}
/**
* Activate the license for this domain.
*
* @return array{success: bool, message: string, data?: array}
*/
public function activate(): array {
if ( ! $this->init_client() ) {
return array(
'success' => false,
'message' => __( 'License server configuration is incomplete.', 'wp-fedistream' ),
);
}
$license_key = self::get_license_key();
if ( empty( $license_key ) ) {
return array(
'success' => false,
'message' => __( 'No license key provided.', 'wp-fedistream' ),
);
}
$domain = wp_parse_url( home_url(), PHP_URL_HOST );
try {
$result = $this->client->activate( $license_key, $domain );
if ( $result->success ) {
// Validate after activation to get full license info.
return $this->validate();
}
return array(
'success' => false,
'message' => $result->message,
);
} catch ( MaxActivationsReachedException $e ) {
return array(
'success' => false,
'message' => __( 'Maximum number of activations reached. Please deactivate another site first.', 'wp-fedistream' ),
);
} catch ( LicenseNotFoundException $e ) {
return array(
'success' => false,
'message' => __( 'License key not found. Please check your license key.', 'wp-fedistream' ),
);
} catch ( LicenseExpiredException $e ) {
return array(
'success' => false,
'message' => __( 'Your license has expired. Please renew to continue.', 'wp-fedistream' ),
);
} catch ( SignatureException $e ) {
return array(
'success' => false,
'message' => __( 'License verification failed. Please check your server secret.', 'wp-fedistream' ),
);
} catch ( LicenseException $e ) {
return array(
'success' => false,
'message' => sprintf(
/* translators: %s: Error message */
__( 'License activation failed: %s', 'wp-fedistream' ),
$e->getMessage()
),
);
} catch ( \Throwable $e ) {
return array(
'success' => false,
'message' => __( 'Unable to activate license. Please try again later.', 'wp-fedistream' ),
);
}
}
/**
* Get the current license status.
*
* @param bool $force_refresh Force a fresh check from the server.
* @return array{success: bool, message: string, data?: array}
*/
public function get_status( bool $force_refresh = false ): array {
// Check cached status first.
if ( ! $force_refresh ) {
$cached = $this->get_cached_validation();
if ( null !== $cached ) {
return $cached;
}
}
if ( ! $this->init_client() ) {
return array(
'success' => false,
'message' => __( 'License server configuration is incomplete.', 'wp-fedistream' ),
'data' => array(
'status' => 'unconfigured',
),
);
}
$license_key = self::get_license_key();
if ( empty( $license_key ) ) {
return array(
'success' => false,
'message' => __( 'No license key configured.', 'wp-fedistream' ),
'data' => array(
'status' => 'unchecked',
),
);
}
try {
$result = $this->client->status( $license_key );
$status_map = array(
LicenseState::Active->value => 'valid',
LicenseState::Inactive->value => 'inactive',
LicenseState::Expired->value => 'expired',
LicenseState::Revoked->value => 'revoked',
);
$status = $status_map[ $result->status->value ] ?? 'invalid';
$data = array(
'status' => $status,
'valid' => $result->valid,
'domain' => $result->domain,
'expires_at' => $result->expiresAt?->format( 'Y-m-d' ),
'lifetime' => $result->isLifetime(),
'activations_count' => $result->activationsCount,
'max_activations' => $result->maxActivations,
);
// Cache the result.
$this->cache_validation( array(
'success' => $result->valid,
'message' => $result->valid
? __( 'License is active.', 'wp-fedistream' )
: __( 'License is not active.', 'wp-fedistream' ),
'data' => $data,
) );
$this->update_cached_status( $status, $data );
return array(
'success' => $result->valid,
'message' => $result->valid
? __( 'License is active.', 'wp-fedistream' )
: __( 'License is not active.', 'wp-fedistream' ),
'data' => $data,
);
} catch ( LicenseNotFoundException $e ) {
$this->update_cached_status( 'invalid' );
return array(
'success' => false,
'message' => __( 'License key not found.', 'wp-fedistream' ),
'data' => array(
'status' => 'invalid',
),
);
} catch ( SignatureException $e ) {
return array(
'success' => false,
'message' => __( 'License verification failed. Please check your server secret.', 'wp-fedistream' ),
'data' => array(
'status' => 'error',
),
);
} catch ( \Throwable $e ) {
return array(
'success' => false,
'message' => __( 'Unable to check license status.', 'wp-fedistream' ),
'data' => array(
'status' => 'error',
),
);
}
}
/**
* Deactivate the license (clear local data).
*
* @return bool
*/
public function deactivate(): bool {
self::clear_license_data();
return true;
}
/**
* Check if the license is currently valid.
*
* Uses cached status for performance. Bypasses license check on localhost.
*
* @return bool
*/
public static function is_license_valid(): bool {
// Bypass license check on localhost for development.
if ( self::is_localhost() ) {
return true;
}
$status = get_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
return 'valid' === $status;
}
/**
* Check if the current site is running on localhost.
*
* @return bool
*/
public static function is_localhost(): bool {
$host = wp_parse_url( home_url(), PHP_URL_HOST );
$localhost_patterns = array(
'localhost',
'127.0.0.1',
'::1',
);
// Check exact matches.
if ( in_array( $host, $localhost_patterns, true ) ) {
return true;
}
// Check .localhost TLD (e.g., mysite.localhost).
if ( str_ends_with( $host, '.localhost' ) ) {
return true;
}
// Check .local TLD (common for local development).
if ( str_ends_with( $host, '.local' ) ) {
return true;
}
return false;
}
/**
* Get the license key.
*
* @return string
*/
public static function get_license_key(): string {
return get_option( self::OPTION_LICENSE_KEY, '' );
}
/**
* Get the license server URL.
*
* @return string
*/
public static function get_server_url(): string {
return get_option( self::OPTION_SERVER_URL, '' );
}
/**
* Get the server secret.
*
* @return string
*/
public static function get_server_secret(): string {
return get_option( self::OPTION_SERVER_SECRET, '' );
}
/**
* Get cached license status.
*
* @return string
*/
public static function get_cached_status(): string {
return get_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
}
/**
* Get cached license data.
*
* @return array
*/
public static function get_cached_data(): array {
return get_option( self::OPTION_LICENSE_DATA, array() );
}
/**
* Get last check timestamp.
*
* @return int
*/
public static function get_last_check(): int {
return (int) get_option( self::OPTION_LAST_CHECK, 0 );
}
/**
* Save license settings.
*
* @param array $data Settings data.
* @return bool
*/
public static function save_settings( array $data ): bool {
if ( isset( $data['license_key'] ) ) {
update_option( self::OPTION_LICENSE_KEY, sanitize_text_field( $data['license_key'] ) );
}
if ( isset( $data['server_url'] ) ) {
update_option( self::OPTION_SERVER_URL, esc_url_raw( $data['server_url'] ) );
}
if ( isset( $data['server_secret'] ) ) {
// Only update if a new secret is provided.
$secret = sanitize_text_field( $data['server_secret'] );
if ( ! empty( $secret ) ) {
update_option( self::OPTION_SERVER_SECRET, $secret );
}
}
// Reset status when settings change.
update_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
delete_transient( self::TRANSIENT_LICENSE_CHECK );
return true;
}
/**
* Clear all license data.
*
* @return void
*/
public static function clear_license_data(): void {
update_option( self::OPTION_LICENSE_STATUS, 'unchecked' );
update_option( self::OPTION_LICENSE_DATA, array() );
update_option( self::OPTION_LAST_CHECK, 0 );
delete_transient( self::TRANSIENT_LICENSE_CHECK );
}
/**
* Update cached license status.
*
* @param string $status Status value.
* @param array $data Additional data.
* @return void
*/
private function update_cached_status( string $status, array $data = array() ): void {
update_option( self::OPTION_LICENSE_STATUS, $status );
update_option( self::OPTION_LICENSE_DATA, $data );
update_option( self::OPTION_LAST_CHECK, time() );
}
/**
* Cache validation result.
*
* @param array $result Validation result.
* @return void
*/
private function cache_validation( array $result ): void {
set_transient( self::TRANSIENT_LICENSE_CHECK, $result, self::CACHE_TTL );
}
/**
* Get cached validation result.
*
* @return array|null
*/
private function get_cached_validation(): ?array {
$cached = get_transient( self::TRANSIENT_LICENSE_CHECK );
return false === $cached ? null : $cached;
}
/**
* AJAX handler: Validate license.
*
* @return void
*/
public function ajax_validate_license(): void {
check_ajax_referer( 'fedistream_license_action', 'nonce' );
if ( ! current_user_can( 'manage_fedistream_settings' ) ) {
wp_send_json_error( array(
'message' => __( 'You do not have permission to perform this action.', 'wp-fedistream' ),
) );
}
$result = $this->validate();
if ( $result['success'] ) {
wp_send_json_success( $result );
} else {
wp_send_json_error( $result );
}
}
/**
* AJAX handler: Activate license.
*
* @return void
*/
public function ajax_activate_license(): void {
check_ajax_referer( 'fedistream_license_action', 'nonce' );
if ( ! current_user_can( 'manage_fedistream_settings' ) ) {
wp_send_json_error( array(
'message' => __( 'You do not have permission to perform this action.', 'wp-fedistream' ),
) );
}
$result = $this->activate();
if ( $result['success'] ) {
wp_send_json_success( $result );
} else {
wp_send_json_error( $result );
}
}
/**
* AJAX handler: Deactivate license.
*
* @return void
*/
public function ajax_deactivate_license(): void {
check_ajax_referer( 'fedistream_license_action', 'nonce' );
if ( ! current_user_can( 'manage_fedistream_settings' ) ) {
wp_send_json_error( array(
'message' => __( 'You do not have permission to perform this action.', 'wp-fedistream' ),
) );
}
$this->deactivate();
wp_send_json_success( array(
'success' => true,
'message' => __( 'License deactivated.', 'wp-fedistream' ),
) );
}
/**
* AJAX handler: Check license status.
*
* @return void
*/
public function ajax_check_status(): void {
check_ajax_referer( 'fedistream_license_action', 'nonce' );
if ( ! current_user_can( 'manage_fedistream_settings' ) ) {
wp_send_json_error( array(
'message' => __( 'You do not have permission to perform this action.', 'wp-fedistream' ),
) );
}
$force_refresh = isset( $_POST['force'] ) && 'true' === $_POST['force'];
$result = $this->get_status( $force_refresh );
if ( $result['success'] ) {
wp_send_json_success( $result );
} else {
wp_send_json_error( $result );
}
}
}

View File

@@ -16,6 +16,7 @@ use WP_FediStream\Frontend\TemplateLoader;
use WP_FediStream\Frontend\Widgets; use WP_FediStream\Frontend\Widgets;
use WP_FediStream\PostTypes\Artist; use WP_FediStream\PostTypes\Artist;
use WP_FediStream\WooCommerce\Integration as WooCommerceIntegration; use WP_FediStream\WooCommerce\Integration as WooCommerceIntegration;
use WP_FediStream\Prometheus\Integration as PrometheusIntegration;
use WP_FediStream\WooCommerce\DigitalDelivery; use WP_FediStream\WooCommerce\DigitalDelivery;
use WP_FediStream\WooCommerce\StreamingAccess; use WP_FediStream\WooCommerce\StreamingAccess;
use WP_FediStream\PostTypes\Album; use WP_FediStream\PostTypes\Album;
@@ -23,10 +24,11 @@ use WP_FediStream\PostTypes\Track;
use WP_FediStream\PostTypes\Playlist; use WP_FediStream\PostTypes\Playlist;
use WP_FediStream\Taxonomies\Genre; use WP_FediStream\Taxonomies\Genre;
use WP_FediStream\Taxonomies\Mood; use WP_FediStream\Taxonomies\Mood;
use WP_FediStream\Taxonomies\License; use WP_FediStream\Taxonomies\License as LicenseTaxonomy;
use WP_FediStream\User\Library as UserLibrary; use WP_FediStream\User\Library as UserLibrary;
use WP_FediStream\User\LibraryPage; use WP_FediStream\User\LibraryPage;
use WP_FediStream\User\Notifications; use WP_FediStream\User\Notifications;
use WP_FediStream\License\Manager as LicenseManager;
// Prevent direct file access. // Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) { if ( ! defined( 'ABSPATH' ) ) {
@@ -48,11 +50,27 @@ final class Plugin {
private static ?Plugin $instance = null; private static ?Plugin $instance = null;
/** /**
* Twig environment instance. * Current render depth to prevent infinite recursion.
* *
* @var \Twig\Environment|null * @var int
*/ */
private ?\Twig\Environment $twig = null; private static int $render_depth = 0;
/**
* Maximum allowed render depth.
* Set to 2 to allow one level of nested includes but prevent deeper recursion.
*
* @var int
*/
private const MAX_RENDER_DEPTH = 2;
/**
* Flag to track if we're currently rendering the main page template.
* This is a hard lock that prevents ANY other rendering.
*
* @var bool
*/
private static bool $rendering_main_template = false;
/** /**
* Post type instances. * Post type instances.
@@ -84,7 +102,6 @@ final class Plugin {
* Private constructor to enforce singleton pattern. * Private constructor to enforce singleton pattern.
*/ */
private function __construct() { private function __construct() {
$this->init_twig();
$this->init_components(); $this->init_components();
$this->init_hooks(); $this->init_hooks();
$this->load_textdomain(); $this->load_textdomain();
@@ -107,32 +124,6 @@ final class Plugin {
throw new \Exception( 'Cannot unserialize singleton' ); throw new \Exception( 'Cannot unserialize singleton' );
} }
/**
* Initialize Twig template engine.
*
* @return void
*/
private function init_twig(): void {
$loader = new \Twig\Loader\FilesystemLoader( WP_FEDISTREAM_PATH . 'templates' );
$this->twig = new \Twig\Environment(
$loader,
array(
'cache' => WP_FEDISTREAM_PATH . 'cache/twig',
'auto_reload' => WP_DEBUG,
)
);
// Add WordPress escaping functions.
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_html', 'esc_html' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_attr', 'esc_attr' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_url', 'esc_url' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'esc_js', 'esc_js' ) );
$this->twig->addFunction( new \Twig\TwigFunction( 'wp_nonce_field', 'wp_nonce_field', array( 'is_safe' => array( 'html' ) ) ) );
$this->twig->addFunction( new \Twig\TwigFunction( '__', '__' ) );
$this->twig->addFunction( new \Twig\TwigFunction( '_e', '_e' ) );
}
/** /**
* Initialize plugin components. * Initialize plugin components.
* *
@@ -148,17 +139,20 @@ final class Plugin {
// Initialize taxonomies. // Initialize taxonomies.
$this->taxonomies['genre'] = new Genre(); $this->taxonomies['genre'] = new Genre();
$this->taxonomies['mood'] = new Mood(); $this->taxonomies['mood'] = new Mood();
$this->taxonomies['license'] = new License(); $this->taxonomies['license'] = new LicenseTaxonomy();
// Initialize admin components. // Initialize admin components.
if ( is_admin() ) { if ( is_admin() ) {
new ListColumns(); new ListColumns();
} }
// Initialize frontend components. // Initialize frontend components (only if licensed).
if ( ! is_admin() ) { if ( ! is_admin() && LicenseManager::is_license_valid() ) {
new TemplateLoader(); new TemplateLoader();
new Shortcodes(); new Shortcodes();
} elseif ( ! is_admin() ) {
// Register shortcodes that show license message.
new Shortcodes( true ); // Unlicensed mode.
} }
// Initialize widgets (always needed for admin widget management). // Initialize widgets (always needed for admin widget management).
@@ -167,8 +161,8 @@ final class Plugin {
// Initialize AJAX handlers. // Initialize AJAX handlers.
new Ajax(); new Ajax();
// Initialize ActivityPub integration. // Initialize ActivityPub integration (only if licensed and enabled).
if ( get_option( 'wp_fedistream_enable_activitypub', 1 ) ) { if ( get_option( 'wp_fedistream_enable_activitypub', 1 ) && LicenseManager::is_license_valid() ) {
new ActivityPubIntegration(); new ActivityPubIntegration();
new ActivityPubRestApi(); new ActivityPubRestApi();
} }
@@ -180,10 +174,18 @@ final class Plugin {
new StreamingAccess(); new StreamingAccess();
} }
// Initialize Prometheus integration.
if ( get_option( 'wp_fedistream_enable_prometheus', 0 ) && $this->is_prometheus_active() ) {
new PrometheusIntegration();
}
// Initialize user library and notifications. // Initialize user library and notifications.
new UserLibrary(); new UserLibrary();
new LibraryPage(); new LibraryPage();
new Notifications(); new Notifications();
// Initialize license manager.
LicenseManager::get_instance();
} }
/** /**
@@ -409,53 +411,251 @@ final class Plugin {
return; return;
} }
// Save settings. // Get current tab.
if ( isset( $_POST['fedistream_settings_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['fedistream_settings_nonce'] ), 'fedistream_save_settings' ) ) { $current_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'license';
update_option( 'wp_fedistream_enable_activitypub', isset( $_POST['enable_activitypub'] ) ? 1 : 0 );
update_option( 'wp_fedistream_enable_woocommerce', isset( $_POST['enable_woocommerce'] ) ? 1 : 0 );
update_option( 'wp_fedistream_max_upload_size', absint( $_POST['max_upload_size'] ?? 50 ) );
update_option( 'wp_fedistream_default_license', sanitize_text_field( wp_unslash( $_POST['default_license'] ?? 'all-rights-reserved' ) ) );
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved.', 'wp-fedistream' ) . '</p></div>'; // Handle form submissions.
} $this->handle_settings_save( $current_tab );
// Get current settings. // Get current settings.
$enable_activitypub = get_option( 'wp_fedistream_enable_activitypub', 1 ); $enable_activitypub = get_option( 'wp_fedistream_enable_activitypub', 1 );
$enable_woocommerce = get_option( 'wp_fedistream_enable_woocommerce', 0 ); $enable_woocommerce = get_option( 'wp_fedistream_enable_woocommerce', 0 );
$enable_prometheus = get_option( 'wp_fedistream_enable_prometheus', 0 );
$max_upload_size = get_option( 'wp_fedistream_max_upload_size', 50 ); $max_upload_size = get_option( 'wp_fedistream_max_upload_size', 50 );
$default_license = get_option( 'wp_fedistream_default_license', 'all-rights-reserved' ); $default_license = get_option( 'wp_fedistream_default_license', 'all-rights-reserved' );
// License settings.
$license_key = LicenseManager::get_license_key();
$license_server_url = LicenseManager::get_server_url();
$license_status = LicenseManager::get_cached_status();
$license_data = LicenseManager::get_cached_data();
$last_check = LicenseManager::get_last_check();
$tabs = array(
'license' => __( 'License', 'wp-fedistream' ),
'settings' => __( 'Default Settings', 'wp-fedistream' ),
'integrations' => __( 'Integrations', 'wp-fedistream' ),
);
?> ?>
<div class="wrap"> <div class="wrap">
<h1><?php esc_html_e( 'FediStream Settings', 'wp-fedistream' ); ?></h1> <h1><?php esc_html_e( 'FediStream Settings', 'wp-fedistream' ); ?></h1>
<form method="post" action=""> <nav class="nav-tab-wrapper wp-clearfix">
<?php foreach ( $tabs as $tab_key => $tab_label ) : ?>
<a href="<?php echo esc_url( admin_url( 'admin.php?page=fedistream-settings&tab=' . $tab_key ) ); ?>"
class="nav-tab <?php echo $current_tab === $tab_key ? 'nav-tab-active' : ''; ?>">
<?php echo esc_html( $tab_label ); ?>
</a>
<?php endforeach; ?>
</nav>
<div class="fedistream-settings-content">
<?php
switch ( $current_tab ) {
case 'license':
$this->render_license_tab( $license_key, $license_server_url, $license_status, $license_data, $last_check );
break;
case 'settings':
$this->render_settings_tab( $max_upload_size, $default_license );
break;
case 'integrations':
$this->render_integrations_tab( $enable_activitypub, $enable_woocommerce, $enable_prometheus );
break;
}
?>
</div>
</div>
<?php
}
/**
* Handle settings form submission.
*
* @param string $tab Current tab.
* @return void
*/
private function handle_settings_save( string $tab ): void {
if ( ! isset( $_POST['fedistream_settings_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['fedistream_settings_nonce'] ), 'fedistream_save_settings' ) ) {
return;
}
switch ( $tab ) {
case 'license':
LicenseManager::save_settings( array(
'license_key' => isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : '',
'server_url' => isset( $_POST['license_server_url'] ) ? esc_url_raw( wp_unslash( $_POST['license_server_url'] ) ) : '',
'server_secret' => isset( $_POST['license_server_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['license_server_secret'] ) ) : '',
) );
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'License settings saved.', 'wp-fedistream' ) . '</p></div>';
break;
case 'settings':
update_option( 'wp_fedistream_max_upload_size', absint( $_POST['max_upload_size'] ?? 50 ) );
update_option( 'wp_fedistream_default_license', sanitize_text_field( wp_unslash( $_POST['default_license'] ?? 'all-rights-reserved' ) ) );
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved.', 'wp-fedistream' ) . '</p></div>';
break;
case 'integrations':
update_option( 'wp_fedistream_enable_activitypub', isset( $_POST['enable_activitypub'] ) ? 1 : 0 );
update_option( 'wp_fedistream_enable_woocommerce', isset( $_POST['enable_woocommerce'] ) ? 1 : 0 );
update_option( 'wp_fedistream_enable_prometheus', isset( $_POST['enable_prometheus'] ) ? 1 : 0 );
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Integration settings saved.', 'wp-fedistream' ) . '</p></div>';
break;
}
}
/**
* Render the License tab.
*
* @param string $license_key License key.
* @param string $server_url Server URL.
* @param string $status License status.
* @param array $license_data License data.
* @param int $last_check Last check timestamp.
* @return void
*/
private function render_license_tab( string $license_key, string $server_url, string $status, array $license_data, int $last_check ): void {
$status_classes = array(
'valid' => 'notice-success',
'invalid' => 'notice-error',
'expired' => 'notice-warning',
'revoked' => 'notice-error',
'inactive' => 'notice-warning',
'unchecked' => 'notice-info',
'unconfigured' => 'notice-info',
);
$status_messages = array(
'valid' => __( 'License is active and valid.', 'wp-fedistream' ),
'invalid' => __( 'License is invalid.', 'wp-fedistream' ),
'expired' => __( 'License has expired.', 'wp-fedistream' ),
'revoked' => __( 'License has been revoked.', 'wp-fedistream' ),
'inactive' => __( 'License is inactive. Please activate it.', 'wp-fedistream' ),
'unchecked' => __( 'License has not been validated yet.', 'wp-fedistream' ),
'unconfigured' => __( 'License server is not configured.', 'wp-fedistream' ),
);
$status_icons = array(
'valid' => 'dashicons-yes-alt',
'invalid' => 'dashicons-dismiss',
'expired' => 'dashicons-warning',
'revoked' => 'dashicons-dismiss',
'inactive' => 'dashicons-marker',
'unchecked' => 'dashicons-info-outline',
'unconfigured' => 'dashicons-admin-generic',
);
$status_class = $status_classes[ $status ] ?? 'notice-info';
$status_message = $status_messages[ $status ] ?? __( 'Unknown status.', 'wp-fedistream' );
$status_icon = $status_icons[ $status ] ?? 'dashicons-info-outline';
?>
<div class="fedistream-license-status notice <?php echo esc_attr( $status_class ); ?>" style="padding: 12px; display: flex; align-items: center; gap: 10px;">
<span class="dashicons <?php echo esc_attr( $status_icon ); ?>" style="font-size: 24px; width: 24px; height: 24px;"></span>
<div>
<strong><?php echo esc_html( $status_message ); ?></strong>
<?php if ( 'valid' === $status && ! empty( $license_data['expires_at'] ) ) : ?>
<br>
<span class="description">
<?php
printf(
/* translators: %s: Expiration date */
esc_html__( 'Expires: %s', 'wp-fedistream' ),
esc_html( $license_data['expires_at'] )
);
?>
</span>
<?php elseif ( 'valid' === $status && empty( $license_data['expires_at'] ) ) : ?>
<br>
<span class="description"><?php esc_html_e( 'Lifetime license', 'wp-fedistream' ); ?></span>
<?php endif; ?>
<?php if ( $last_check > 0 ) : ?>
<br>
<span class="description">
<?php
printf(
/* translators: %s: Time ago */
esc_html__( 'Last checked: %s', 'wp-fedistream' ),
esc_html( human_time_diff( $last_check, time() ) . ' ' . __( 'ago', 'wp-fedistream' ) )
);
?>
</span>
<?php endif; ?>
</div>
</div>
<form method="post" action="" id="fedistream-license-form">
<?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?> <?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?>
<table class="form-table"> <table class="form-table">
<tr> <tr>
<th scope="row"><?php esc_html_e( 'ActivityPub Integration', 'wp-fedistream' ); ?></th> <th scope="row">
<label for="license_server_url"><?php esc_html_e( 'License Server URL', 'wp-fedistream' ); ?></label>
</th>
<td> <td>
<label> <input type="url" name="license_server_url" id="license_server_url"
<input type="checkbox" name="enable_activitypub" value="1" <?php checked( $enable_activitypub, 1 ); ?>> value="<?php echo esc_attr( $server_url ); ?>"
<?php esc_html_e( 'Enable ActivityPub features', 'wp-fedistream' ); ?> class="regular-text" placeholder="https://example.com">
</label> <p class="description"><?php esc_html_e( 'The URL of your license server.', 'wp-fedistream' ); ?></p>
<p class="description"><?php esc_html_e( 'Publish releases to the Fediverse and allow followers.', 'wp-fedistream' ); ?></p>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><?php esc_html_e( 'WooCommerce Integration', 'wp-fedistream' ); ?></th> <th scope="row">
<label for="license_key"><?php esc_html_e( 'License Key', 'wp-fedistream' ); ?></label>
</th>
<td> <td>
<label> <input type="text" name="license_key" id="license_key"
<input type="checkbox" name="enable_woocommerce" value="1" <?php checked( $enable_woocommerce, 1 ); ?> <?php disabled( ! $this->is_woocommerce_active() ); ?>> value="<?php echo esc_attr( $license_key ); ?>"
<?php esc_html_e( 'Enable WooCommerce features', 'wp-fedistream' ); ?> class="regular-text" placeholder="XXXX-XXXX-XXXX-XXXX">
</label> <p class="description"><?php esc_html_e( 'Your license key from your purchase.', 'wp-fedistream' ); ?></p>
<?php if ( ! $this->is_woocommerce_active() ) : ?>
<p class="description" style="color: #d63638;"><?php esc_html_e( 'WooCommerce is not installed or active.', 'wp-fedistream' ); ?></p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Sell albums and tracks through WooCommerce.', 'wp-fedistream' ); ?></p>
<?php endif; ?>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">
<label for="license_server_secret"><?php esc_html_e( 'Server Secret', 'wp-fedistream' ); ?></label>
</th>
<td>
<input type="password" name="license_server_secret" id="license_server_secret"
value="" class="regular-text" placeholder="<?php echo esc_attr( ! empty( LicenseManager::get_server_secret() ) ? '••••••••••••••••' : '' ); ?>">
<p class="description"><?php esc_html_e( '64-character verification secret from your license account. Leave empty to keep existing.', 'wp-fedistream' ); ?></p>
</td>
</tr>
</table>
<p class="submit">
<?php submit_button( __( 'Save License Settings', 'wp-fedistream' ), 'primary', 'submit', false ); ?>
<button type="button" id="fedistream-validate-license" class="button button-secondary" style="margin-left: 10px;">
<span class="dashicons dashicons-yes" style="vertical-align: middle; margin-top: -2px;"></span>
<?php esc_html_e( 'Validate License', 'wp-fedistream' ); ?>
</button>
<button type="button" id="fedistream-activate-license" class="button button-secondary" style="margin-left: 10px;">
<span class="dashicons dashicons-admin-network" style="vertical-align: middle; margin-top: -2px;"></span>
<?php esc_html_e( 'Activate License', 'wp-fedistream' ); ?>
</button>
<span id="fedistream-license-spinner" class="spinner" style="float: none; margin-top: 4px;"></span>
</p>
</form>
<div id="fedistream-license-message" style="display: none; margin-top: 10px;"></div>
<script type="text/javascript">
var fedistreamLicenseNonce = '<?php echo esc_js( wp_create_nonce( 'fedistream_license_action' ) ); ?>';
</script>
<?php
}
/**
* Render the Default Settings tab.
*
* @param int $max_upload_size Max upload size in MB.
* @param string $default_license Default license.
* @return void
*/
private function render_settings_tab( int $max_upload_size, string $default_license ): void {
?>
<form method="post" action="">
<?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?>
<table class="form-table">
<tr> <tr>
<th scope="row"> <th scope="row">
<label for="max_upload_size"><?php esc_html_e( 'Max Upload Size', 'wp-fedistream' ); ?></label> <label for="max_upload_size"><?php esc_html_e( 'Max Upload Size', 'wp-fedistream' ); ?></label>
@@ -485,7 +685,77 @@ final class Plugin {
<?php submit_button(); ?> <?php submit_button(); ?>
</form> </form>
</div> <?php
}
/**
* Render the Integrations tab.
*
* @param int $enable_activitypub Whether ActivityPub is enabled.
* @param int $enable_woocommerce Whether WooCommerce integration is enabled.
* @param int $enable_prometheus Whether Prometheus metrics are enabled.
* @return void
*/
private function render_integrations_tab( int $enable_activitypub, int $enable_woocommerce, int $enable_prometheus ): void {
?>
<form method="post" action="">
<?php wp_nonce_field( 'fedistream_save_settings', 'fedistream_settings_nonce' ); ?>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'ActivityPub Integration', 'wp-fedistream' ); ?></th>
<td>
<label>
<input type="checkbox" name="enable_activitypub" value="1" <?php checked( $enable_activitypub, 1 ); ?>>
<?php esc_html_e( 'Enable ActivityPub features', 'wp-fedistream' ); ?>
</label>
<p class="description"><?php esc_html_e( 'Publish releases to the Fediverse and allow followers.', 'wp-fedistream' ); ?></p>
<?php if ( ! $this->is_activitypub_active() ) : ?>
<p class="description" style="color: #dba617;">
<span class="dashicons dashicons-warning" style="font-size: 16px; width: 16px; height: 16px; vertical-align: text-bottom;"></span>
<?php esc_html_e( 'The ActivityPub plugin is recommended for full Fediverse integration.', 'wp-fedistream' ); ?>
</p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'WooCommerce Integration', 'wp-fedistream' ); ?></th>
<td>
<label>
<input type="checkbox" name="enable_woocommerce" value="1" <?php checked( $enable_woocommerce, 1 ); ?> <?php disabled( ! $this->is_woocommerce_active() ); ?>>
<?php esc_html_e( 'Enable WooCommerce features', 'wp-fedistream' ); ?>
</label>
<?php if ( ! $this->is_woocommerce_active() ) : ?>
<p class="description" style="color: #d63638;">
<span class="dashicons dashicons-dismiss" style="font-size: 16px; width: 16px; height: 16px; vertical-align: text-bottom;"></span>
<?php esc_html_e( 'WooCommerce is not installed or active.', 'wp-fedistream' ); ?>
</p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Sell albums and tracks through WooCommerce.', 'wp-fedistream' ); ?></p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Prometheus Metrics', 'wp-fedistream' ); ?></th>
<td>
<label>
<input type="checkbox" name="enable_prometheus" value="1" <?php checked( $enable_prometheus, 1 ); ?> <?php disabled( ! $this->is_prometheus_active() ); ?>>
<?php esc_html_e( 'Enable Prometheus metrics', 'wp-fedistream' ); ?>
</label>
<?php if ( ! $this->is_prometheus_active() ) : ?>
<p class="description" style="color: #d63638;">
<span class="dashicons dashicons-dismiss" style="font-size: 16px; width: 16px; height: 16px; vertical-align: text-bottom;"></span>
<?php esc_html_e( 'WP Prometheus plugin is not installed or active.', 'wp-fedistream' ); ?>
</p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Expose FediStream metrics for Prometheus monitoring.', 'wp-fedistream' ); ?></p>
<?php endif; ?>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<?php <?php
} }
@@ -572,23 +842,89 @@ final class Plugin {
} }
/** /**
* Get Twig environment. * Get the path to a template file.
* *
* @return \Twig\Environment * Checks theme for override first, then falls back to plugin template.
*
* @param string $template Template name (without extension).
* @return string Full path to template file.
*/ */
public function get_twig(): \Twig\Environment { public function get_template_path( string $template ): string {
return $this->twig; // Check theme for override.
$theme_template = locate_template( "fedistream/{$template}.php" );
if ( $theme_template ) {
return $theme_template;
}
// Use plugin template.
return WP_FEDISTREAM_PATH . "templates/{$template}.php";
} }
/** /**
* Render a Twig template. * Render a PHP template.
* *
* @param string $template Template name (without .twig extension). * @param string $template Template name (without extension).
* @param array $context Template context variables. * @param array $context Template context variables.
* @param bool $is_main_template Whether this is the main page template.
* @return string Rendered template. * @return string Rendered template.
*/ */
public function render( string $template, array $context = array() ): string { public function render( string $template, array $context = array(), bool $is_main_template = false ): string {
return $this->twig->render( $template . '.twig', $context ); // If we're already rendering the main template, block any other renders.
if ( self::$rendering_main_template && ! $is_main_template ) {
return '<!-- FediStream: blocked during main template render -->';
}
// Prevent infinite recursion in rendering.
if ( self::$render_depth >= self::MAX_RENDER_DEPTH ) {
return '<!-- FediStream: render depth exceeded -->';
}
// Set main template lock if this is the main template.
$was_main = self::$rendering_main_template;
if ( $is_main_template ) {
self::$rendering_main_template = true;
}
++self::$render_depth;
try {
$template_path = $this->get_template_path( $template );
if ( ! file_exists( $template_path ) ) {
throw new \Exception( "Template not found: {$template}" );
}
// Extract context variables for use in template.
// phpcs:ignore WordPress.PHP.DontExtract.extract_extract
extract( $context, EXTR_SKIP );
// Start output buffering.
ob_start();
// Include the template.
include $template_path;
// Get the rendered content.
$result = ob_get_clean();
} finally {
--self::$render_depth;
if ( $is_main_template ) {
self::$rendering_main_template = $was_main;
}
}
return $result;
}
/**
* Render a partial template (helper for use within templates).
*
* @param string $partial Partial template name (without extension).
* @param array $context Template context variables.
* @return string Rendered partial.
*/
public function render_partial( string $partial, array $context = array() ): string {
return $this->render( $partial, $context, false );
} }
/** /**
@@ -628,4 +964,13 @@ final class Plugin {
public function is_activitypub_active(): bool { public function is_activitypub_active(): bool {
return class_exists( 'Activitypub\Activitypub' ); return class_exists( 'Activitypub\Activitypub' );
} }
/**
* Check if WP Prometheus plugin is active.
*
* @return bool
*/
public function is_prometheus_active(): bool {
return defined( 'WP_PROMETHEUS_VERSION' ) || class_exists( 'WP_Prometheus\Plugin' );
}
} }

View File

@@ -0,0 +1,446 @@
<?php
/**
* Prometheus Integration.
*
* @package WP_FediStream
*/
namespace WP_FediStream\Prometheus;
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Main Prometheus integration class.
*
* Exposes FediStream metrics for Prometheus monitoring via the
* wp_prometheus_collect_metrics action hook.
*/
class Integration {
/**
* Whether WP Prometheus is active.
*
* @var bool
*/
private bool $prometheus_active = false;
/**
* Constructor.
*/
public function __construct() {
// Check WP Prometheus immediately since we're instantiated during plugins_loaded.
$this->check_prometheus();
// If plugins_loaded hasn't fully completed, hook init at priority 20.
// Otherwise, run init directly.
if ( ! did_action( 'plugins_loaded' ) || doing_action( 'plugins_loaded' ) ) {
add_action( 'plugins_loaded', array( $this, 'init' ), 20 );
} else {
$this->init();
}
}
/**
* Check if WP Prometheus is active.
*
* @return void
*/
public function check_prometheus(): void {
$this->prometheus_active = defined( 'WP_PROMETHEUS_VERSION' )
|| class_exists( 'WP_Prometheus\Plugin' );
}
/**
* Initialize Prometheus integration.
*
* @return void
*/
public function init(): void {
if ( ! $this->prometheus_active ) {
return;
}
// Register metrics collector.
add_action( 'wp_prometheus_collect_metrics', array( $this, 'collect_metrics' ) );
}
/**
* Check if Prometheus is active.
*
* @return bool
*/
public function is_active(): bool {
return $this->prometheus_active;
}
/**
* Collect metrics for early metrics mode.
*
* This static method can be called directly by wp-prometheus during early
* metrics mode when the wp_prometheus_collect_metrics hook is skipped.
*
* Usage in wp-prometheus early metrics handler:
*
* ```php
* // After creating the collector, before rendering:
* if ( class_exists( 'WP_FediStream\Prometheus\Integration' ) ) {
* \WP_FediStream\Prometheus\Integration::collect_early_metrics( $collector );
* }
* ```
*
* @param object $collector The Prometheus collector instance.
* @return void
*/
public static function collect_early_metrics( object $collector ): void {
$integration = new self();
$integration->prometheus_active = true; // Force active since we're being called directly.
$integration->collect_metrics( $collector );
}
/**
* Collect FediStream metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
public function collect_metrics( $collector ): void {
// Register and collect all metric categories.
$this->collect_content_metrics( $collector );
$this->collect_engagement_metrics( $collector );
$this->collect_user_metrics( $collector );
if ( $this->is_woocommerce_enabled() ) {
$this->collect_woocommerce_metrics( $collector );
}
if ( $this->is_activitypub_enabled() ) {
$this->collect_activitypub_metrics( $collector );
}
}
/**
* Collect content metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_content_metrics( $collector ): void {
// Content count by type and status.
$content_gauge = $collector->register_gauge(
'fedistream_content_total',
'Total FediStream content count',
array( 'type', 'status' )
);
$post_types = array(
'fedistream_artist',
'fedistream_album',
'fedistream_track',
'fedistream_playlist',
);
foreach ( $post_types as $post_type ) {
$type_label = str_replace( 'fedistream_', '', $post_type );
$counts = wp_count_posts( $post_type );
foreach ( array( 'publish', 'draft', 'pending' ) as $status ) {
$count = isset( $counts->$status ) ? (int) $counts->$status : 0;
$content_gauge->set( $count, array( $type_label, $status ) );
}
}
// Genre count.
$genres_gauge = $collector->register_gauge(
'fedistream_genres_total',
'Total number of genres',
array()
);
$genre_count = wp_count_terms( array( 'taxonomy' => 'fedistream_genre' ) );
$genres_gauge->set( is_wp_error( $genre_count ) ? 0 : (int) $genre_count, array() );
// Mood count.
$moods_gauge = $collector->register_gauge(
'fedistream_moods_total',
'Total number of moods',
array()
);
$mood_count = wp_count_terms( array( 'taxonomy' => 'fedistream_mood' ) );
$moods_gauge->set( is_wp_error( $mood_count ) ? 0 : (int) $mood_count, array() );
}
/**
* Collect engagement metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_engagement_metrics( $collector ): void {
global $wpdb;
$plays_table = $wpdb->prefix . 'fedistream_plays';
$favorites_table = $wpdb->prefix . 'fedistream_favorites';
$follows_table = $wpdb->prefix . 'fedistream_user_follows';
$history_table = $wpdb->prefix . 'fedistream_listening_history';
// Total plays.
$plays_gauge = $collector->register_gauge(
'fedistream_plays_total',
'Total track plays',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total_plays = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$plays_table}" );
$plays_gauge->set( $total_plays, array() );
// Plays today.
$plays_today_gauge = $collector->register_gauge(
'fedistream_plays_today',
'Track plays today',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$plays_today = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$plays_table} WHERE DATE(played_at) = CURDATE()"
);
$plays_today_gauge->set( $plays_today, array() );
// Favorites by type.
$favorites_gauge = $collector->register_gauge(
'fedistream_favorites_total',
'Total favorites by content type',
array( 'type' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$favorites_by_type = $wpdb->get_results(
"SELECT content_type, COUNT(*) as count FROM {$favorites_table} GROUP BY content_type"
);
foreach ( $favorites_by_type as $row ) {
$favorites_gauge->set( (int) $row->count, array( $row->content_type ) );
}
// Local follows.
$follows_gauge = $collector->register_gauge(
'fedistream_local_follows_total',
'Total local artist follows',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total_follows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$follows_table}" );
$follows_gauge->set( $total_follows, array() );
// Listening history entries.
$history_gauge = $collector->register_gauge(
'fedistream_listening_history_entries',
'Total listening history entries',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$history_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$history_table}" );
$history_gauge->set( $history_count, array() );
}
/**
* Collect user metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_user_metrics( $collector ): void {
global $wpdb;
$favorites_table = $wpdb->prefix . 'fedistream_favorites';
$follows_table = $wpdb->prefix . 'fedistream_user_follows';
$notifications_table = $wpdb->prefix . 'fedistream_notifications';
// Users with library (favorites).
$users_library_gauge = $collector->register_gauge(
'fedistream_users_with_library',
'Users who have favorites',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$users_with_library = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT user_id) FROM {$favorites_table}"
);
$users_library_gauge->set( $users_with_library, array() );
// Users following artists.
$users_following_gauge = $collector->register_gauge(
'fedistream_users_following_artists',
'Users following at least one artist',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$users_following = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT user_id) FROM {$follows_table}"
);
$users_following_gauge->set( $users_following, array() );
// Notifications by type and status.
$notifications_gauge = $collector->register_gauge(
'fedistream_notifications_total',
'Notifications by type and status',
array( 'type', 'status' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$notifications = $wpdb->get_results(
"SELECT type, is_read, COUNT(*) as count FROM {$notifications_table} GROUP BY type, is_read"
);
foreach ( $notifications as $row ) {
$status = $row->is_read ? 'read' : 'unread';
$notifications_gauge->set( (int) $row->count, array( $row->type, $status ) );
}
// Pending notifications (simple gauge).
$pending_gauge = $collector->register_gauge(
'fedistream_notifications_pending',
'Total unread notifications',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$pending = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$notifications_table} WHERE is_read = 0"
);
$pending_gauge->set( $pending, array() );
}
/**
* Collect WooCommerce metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_woocommerce_metrics( $collector ): void {
global $wpdb;
$purchases_table = $wpdb->prefix . 'fedistream_purchases';
// Purchases by type.
$purchases_gauge = $collector->register_gauge(
'fedistream_purchases_total',
'Total purchases by content type',
array( 'type' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$purchases = $wpdb->get_results(
"SELECT content_type, COUNT(*) as count FROM {$purchases_table} GROUP BY content_type"
);
foreach ( $purchases as $row ) {
$purchases_gauge->set( (int) $row->count, array( $row->content_type ) );
}
// Unique customers.
$customers_gauge = $collector->register_gauge(
'fedistream_customers_total',
'Unique customers with purchases',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$customers = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT user_id) FROM {$purchases_table}"
);
$customers_gauge->set( $customers, array() );
// WooCommerce products count (FediStream types).
if ( function_exists( 'wc_get_products' ) ) {
$products_gauge = $collector->register_gauge(
'fedistream_products_total',
'FediStream products in WooCommerce',
array( 'type' )
);
foreach ( array( 'fedistream_album', 'fedistream_track' ) as $type ) {
$products = wc_get_products(
array(
'type' => $type,
'limit' => -1,
'return' => 'ids',
)
);
$products_gauge->set( count( $products ), array( $type ) );
}
}
}
/**
* Collect ActivityPub metrics.
*
* @param object $collector The Prometheus collector.
* @return void
*/
private function collect_activitypub_metrics( $collector ): void {
global $wpdb;
$followers_table = $wpdb->prefix . 'fedistream_followers';
$reactions_table = $wpdb->prefix . 'fedistream_reactions';
// Total ActivityPub followers.
$followers_gauge = $collector->register_gauge(
'fedistream_activitypub_followers_total',
'Total ActivityPub followers across all artists',
array()
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$total_followers = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$followers_table}" );
$followers_gauge->set( $total_followers, array() );
// Followers by artist (top 10 to avoid cardinality explosion).
$followers_by_artist_gauge = $collector->register_gauge(
'fedistream_activitypub_followers_by_artist',
'Followers per artist',
array( 'artist_id', 'artist_name' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$followers_by_artist = $wpdb->get_results(
"SELECT artist_id, COUNT(*) as count FROM {$followers_table} GROUP BY artist_id ORDER BY count DESC LIMIT 10"
);
foreach ( $followers_by_artist as $row ) {
$artist = get_post( $row->artist_id );
$artist_name = $artist ? $artist->post_title : 'Unknown';
$followers_by_artist_gauge->set( (int) $row->count, array( $row->artist_id, $artist_name ) );
}
// Reactions by type (if table exists).
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$table_exists = $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $reactions_table )
);
if ( $table_exists ) {
$reactions_gauge = $collector->register_gauge(
'fedistream_activitypub_reactions_total',
'Fediverse reactions by type',
array( 'type' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$reactions = $wpdb->get_results(
"SELECT reaction_type, COUNT(*) as count FROM {$reactions_table} GROUP BY reaction_type"
);
foreach ( $reactions as $row ) {
$reactions_gauge->set( (int) $row->count, array( $row->reaction_type ) );
}
}
}
/**
* Check if WooCommerce integration is enabled.
*
* @return bool
*/
private function is_woocommerce_enabled(): bool {
return get_option( 'wp_fedistream_enable_woocommerce', 0 )
&& class_exists( 'WooCommerce' );
}
/**
* Check if ActivityPub integration is enabled.
*
* @return bool
*/
private function is_activitypub_enabled(): bool {
return (bool) get_option( 'wp_fedistream_enable_activitypub', 1 );
}
}

View File

@@ -0,0 +1 @@
<?php // Silence is golden.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
<?php
/**
* Album archive template.
*
* @package WP_FediStream
*
* @var array $posts Array of album data.
* @var string $archive_title Archive title.
* @var string $archive_description Archive description.
* @var array $pagination Pagination data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
?>
<div class="fedistream-archive fedistream-archive--albums">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title"><?php esc_html_e( 'Albums', 'wp-fedistream' ); ?></h1>
<?php if ( ! empty( $archive_description ) ) : ?>
<div class="fedistream-archive__description"><?php echo wp_kses_post( $archive_description ); ?></div>
<?php endif; ?>
</header>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--albums">
<?php foreach ( $posts as $post ) : ?>
<?php echo $plugin->render_partial( 'partials/card-album', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php if ( ! empty( $pagination['links'] ) ) : ?>
<nav class="fedistream-pagination">
<?php
foreach ( $pagination['links'] as $link ) {
echo $link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</nav>
<?php endif; ?>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No albums found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,27 +0,0 @@
{# Album archive template #}
<div class="fedistream-archive fedistream-archive--albums">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title">{{ __('Albums', 'wp-fedistream') }}</h1>
{% if archive_description %}
<div class="fedistream-archive__description">{{ archive_description }}</div>
{% endif %}
</header>
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--albums">
{% for post in posts %}
{% include 'partials/card-album.twig' with { post: post } %}
{% endfor %}
</div>
{% if pagination %}
<nav class="fedistream-pagination">
{{ pagination|raw }}
</nav>
{% endif %}
{% else %}
<div class="fedistream-empty">
<p>{{ __('No albums found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,49 @@
<?php
/**
* Artist archive template.
*
* @package WP_FediStream
*
* @var array $posts Array of artist data.
* @var string $archive_title Archive title.
* @var string $archive_description Archive description.
* @var array $pagination Pagination data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
?>
<div class="fedistream-archive fedistream-archive--artists">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title"><?php esc_html_e( 'Artists', 'wp-fedistream' ); ?></h1>
<?php if ( ! empty( $archive_description ) ) : ?>
<div class="fedistream-archive__description"><?php echo wp_kses_post( $archive_description ); ?></div>
<?php endif; ?>
</header>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--artists">
<?php foreach ( $posts as $post ) : ?>
<?php echo $plugin->render_partial( 'partials/card-artist', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php if ( ! empty( $pagination['links'] ) ) : ?>
<nav class="fedistream-pagination">
<?php
foreach ( $pagination['links'] as $link ) {
echo $link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</nav>
<?php endif; ?>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No artists found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,27 +0,0 @@
{# Artist archive template #}
<div class="fedistream-archive fedistream-archive--artists">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title">{{ __('Artists', 'wp-fedistream') }}</h1>
{% if archive_description %}
<div class="fedistream-archive__description">{{ archive_description }}</div>
{% endif %}
</header>
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--artists">
{% for post in posts %}
{% include 'partials/card-artist.twig' with { post: post } %}
{% endfor %}
</div>
{% if pagination %}
<nav class="fedistream-pagination">
{{ pagination|raw }}
</nav>
{% endif %}
{% else %}
<div class="fedistream-empty">
<p>{{ __('No artists found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,49 @@
<?php
/**
* Playlist archive template.
*
* @package WP_FediStream
*
* @var array $posts Array of playlist data.
* @var string $archive_title Archive title.
* @var string $archive_description Archive description.
* @var array $pagination Pagination data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
?>
<div class="fedistream-archive fedistream-archive--playlists">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title"><?php esc_html_e( 'Playlists', 'wp-fedistream' ); ?></h1>
<?php if ( ! empty( $archive_description ) ) : ?>
<div class="fedistream-archive__description"><?php echo wp_kses_post( $archive_description ); ?></div>
<?php endif; ?>
</header>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--playlists">
<?php foreach ( $posts as $post ) : ?>
<?php echo $plugin->render_partial( 'partials/card-playlist', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php if ( ! empty( $pagination['links'] ) ) : ?>
<nav class="fedistream-pagination">
<?php
foreach ( $pagination['links'] as $link ) {
echo $link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</nav>
<?php endif; ?>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No playlists found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,27 +0,0 @@
{# Playlist archive template #}
<div class="fedistream-archive fedistream-archive--playlists">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title">{{ __('Playlists', 'wp-fedistream') }}</h1>
{% if archive_description %}
<div class="fedistream-archive__description">{{ archive_description }}</div>
{% endif %}
</header>
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--playlists">
{% for post in posts %}
{% include 'partials/card-playlist.twig' with { post: post } %}
{% endfor %}
</div>
{% if pagination %}
<nav class="fedistream-pagination">
{{ pagination|raw }}
</nav>
{% endif %}
{% else %}
<div class="fedistream-empty">
<p>{{ __('No playlists found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,72 @@
<?php
/**
* Taxonomy archive template (Genre, Mood).
*
* @package WP_FediStream
*
* @var array $posts Array of post data.
* @var string $archive_title Archive title.
* @var string $archive_description Archive description.
* @var string $taxonomy_name Taxonomy name (Genre, Mood).
* @var object $term Current term object.
* @var array $pagination Pagination data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
?>
<div class="fedistream-archive fedistream-archive--taxonomy">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title">
<?php if ( ! empty( $taxonomy_name ) ) : ?>
<?php echo esc_html( $taxonomy_name ); ?>:
<?php endif; ?>
<?php echo esc_html( $archive_title ?? '' ); ?>
</h1>
<?php if ( ! empty( $archive_description ) ) : ?>
<div class="fedistream-archive__description"><?php echo wp_kses_post( $archive_description ); ?></div>
<?php endif; ?>
</header>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--mixed">
<?php foreach ( $posts as $post ) : ?>
<?php
$post_type = $post['post_type'] ?? '';
switch ( $post_type ) {
case 'fedistream_artist':
echo $plugin->render_partial( 'partials/card-artist', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
break;
case 'fedistream_album':
echo $plugin->render_partial( 'partials/card-album', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
break;
case 'fedistream_track':
echo $plugin->render_partial( 'partials/card-track', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
break;
case 'fedistream_playlist':
echo $plugin->render_partial( 'partials/card-playlist', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
break;
}
?>
<?php endforeach; ?>
</div>
<?php if ( ! empty( $pagination['links'] ) ) : ?>
<nav class="fedistream-pagination">
<?php
foreach ( $pagination['links'] as $link ) {
echo $link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</nav>
<?php endif; ?>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No content found in this category.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,37 +0,0 @@
{# Taxonomy archive template (Genre, Mood) #}
<div class="fedistream-archive fedistream-archive--taxonomy">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title">
{% if taxonomy_name %}{{ taxonomy_name }}: {% endif %}{{ term.name }}
</h1>
{% if term.description %}
<div class="fedistream-archive__description">{{ term.description }}</div>
{% endif %}
</header>
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--mixed">
{% for post in posts %}
{% if post.post_type == 'fedistream_artist' %}
{% include 'partials/card-artist.twig' with { post: post } %}
{% elseif post.post_type == 'fedistream_album' %}
{% include 'partials/card-album.twig' with { post: post } %}
{% elseif post.post_type == 'fedistream_track' %}
{% include 'partials/card-track.twig' with { post: post } %}
{% elseif post.post_type == 'fedistream_playlist' %}
{% include 'partials/card-playlist.twig' with { post: post } %}
{% endif %}
{% endfor %}
</div>
{% if pagination %}
<nav class="fedistream-pagination">
{{ pagination|raw }}
</nav>
{% endif %}
{% else %}
<div class="fedistream-empty">
<p>{{ __('No content found in this category.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,49 @@
<?php
/**
* Track archive template.
*
* @package WP_FediStream
*
* @var array $posts Array of track data.
* @var string $archive_title Archive title.
* @var string $archive_description Archive description.
* @var array $pagination Pagination data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
?>
<div class="fedistream-archive fedistream-archive--tracks">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title"><?php esc_html_e( 'Tracks', 'wp-fedistream' ); ?></h1>
<?php if ( ! empty( $archive_description ) ) : ?>
<div class="fedistream-archive__description"><?php echo wp_kses_post( $archive_description ); ?></div>
<?php endif; ?>
</header>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--tracks">
<?php foreach ( $posts as $post ) : ?>
<?php echo $plugin->render_partial( 'partials/card-track', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php if ( ! empty( $pagination['links'] ) ) : ?>
<nav class="fedistream-pagination">
<?php
foreach ( $pagination['links'] as $link ) {
echo $link; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
?>
</nav>
<?php endif; ?>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No tracks found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,27 +0,0 @@
{# Track archive template #}
<div class="fedistream-archive fedistream-archive--tracks">
<header class="fedistream-archive__header">
<h1 class="fedistream-archive__title">{{ __('Tracks', 'wp-fedistream') }}</h1>
{% if archive_description %}
<div class="fedistream-archive__description">{{ archive_description }}</div>
{% endif %}
</header>
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--tracks">
{% for post in posts %}
{% include 'partials/card-track.twig' with { post: post } %}
{% endfor %}
</div>
{% if pagination %}
<nav class="fedistream-pagination">
{{ pagination|raw }}
</nav>
{% endif %}
{% else %}
<div class="fedistream-empty">
<p>{{ __('No tracks found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,50 @@
<?php
/**
* Album card partial template.
*
* @package WP_FediStream
*
* @var array $post Album data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-card fedistream-card--album">
<a href="<?php echo esc_url( $post['permalink'] ?? '' ); ?>" class="fedistream-card__link">
<div class="fedistream-card__image fedistream-card__image--square">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" loading="lazy">
<?php else : ?>
<div class="fedistream-card__placeholder fedistream-card__placeholder--album">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</div>
<?php endif; ?>
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h3>
<?php if ( ! empty( $post['artist_name'] ) ) : ?>
<p class="fedistream-card__artist"><?php echo esc_html( $post['artist_name'] ); ?></p>
<?php endif; ?>
<p class="fedistream-card__meta">
<span class="fedistream-card__type"><?php echo esc_html( $post['album_type_label'] ?? '' ); ?></span>
<?php if ( ! empty( $post['release_year'] ) ) : ?>
<span class="fedistream-card__year"><?php echo esc_html( $post['release_year'] ); ?></span>
<?php endif; ?>
</p>
<?php if ( ! empty( $post['total_tracks'] ) && $post['total_tracks'] > 0 ) : ?>
<p class="fedistream-card__stats">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['total_tracks'], 'wp-fedistream' ) ),
(int) $post['total_tracks']
);
?>
</p>
<?php endif; ?>
</div>
</a>
</article>

View File

@@ -1,31 +0,0 @@
{# Album card partial #}
<article class="fedistream-card fedistream-card--album">
<a href="{{ post.permalink }}" class="fedistream-card__link">
<div class="fedistream-card__image fedistream-card__image--square">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" loading="lazy">
{% else %}
<div class="fedistream-card__placeholder fedistream-card__placeholder--album">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</div>
{% endif %}
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title">{{ post.title }}</h3>
{% if post.artist_name %}
<p class="fedistream-card__artist">{{ post.artist_name }}</p>
{% endif %}
<p class="fedistream-card__meta">
<span class="fedistream-card__type">{{ post.album_type_label }}</span>
{% if post.release_year %}
<span class="fedistream-card__year">{{ post.release_year }}</span>
{% endif %}
</p>
{% if post.total_tracks > 0 %}
<p class="fedistream-card__stats">
{{ post.total_tracks }} {{ post.total_tracks == 1 ? 'track' : 'tracks' }}
</p>
{% endif %}
</div>
</a>
</article>

View File

@@ -0,0 +1,47 @@
<?php
/**
* Artist card partial template.
*
* @package WP_FediStream
*
* @var array $post Artist data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-card fedistream-card--artist">
<a href="<?php echo esc_url( $post['permalink'] ?? '' ); ?>" class="fedistream-card__link">
<div class="fedistream-card__image">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" loading="lazy">
<?php else : ?>
<div class="fedistream-card__placeholder fedistream-card__placeholder--artist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>
<?php endif; ?>
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h3>
<p class="fedistream-card__meta">
<span class="fedistream-card__type"><?php echo esc_html( $post['artist_type_label'] ?? '' ); ?></span>
<?php if ( ! empty( $post['location'] ) ) : ?>
<span class="fedistream-card__location"><?php echo esc_html( $post['location'] ); ?></span>
<?php endif; ?>
</p>
<?php if ( isset( $post['album_count'] ) && $post['album_count'] > 0 ) : ?>
<p class="fedistream-card__stats">
<?php
printf(
/* translators: %d: number of albums */
esc_html( _n( '%d album', '%d albums', $post['album_count'], 'wp-fedistream' ) ),
(int) $post['album_count']
);
?>
</p>
<?php endif; ?>
</div>
</a>
</article>

View File

@@ -1,28 +0,0 @@
{# Artist card partial #}
<article class="fedistream-card fedistream-card--artist">
<a href="{{ post.permalink }}" class="fedistream-card__link">
<div class="fedistream-card__image">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" loading="lazy">
{% else %}
<div class="fedistream-card__placeholder fedistream-card__placeholder--artist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>
{% endif %}
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title">{{ post.title }}</h3>
<p class="fedistream-card__meta">
<span class="fedistream-card__type">{{ post.artist_type_label }}</span>
{% if post.location %}
<span class="fedistream-card__location">{{ post.location }}</span>
{% endif %}
</p>
{% if post.album_count is defined and post.album_count > 0 %}
<p class="fedistream-card__stats">
{{ post.album_count }} {{ post.album_count == 1 ? 'album' : 'albums' }}
</p>
{% endif %}
</div>
</a>
</article>

View File

@@ -0,0 +1,54 @@
<?php
/**
* Playlist card partial template.
*
* @package WP_FediStream
*
* @var array $post Playlist data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-card fedistream-card--playlist">
<a href="<?php echo esc_url( $post['permalink'] ?? '' ); ?>" class="fedistream-card__link">
<div class="fedistream-card__image fedistream-card__image--square">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" loading="lazy">
<?php else : ?>
<div class="fedistream-card__placeholder fedistream-card__placeholder--playlist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</div>
<?php endif; ?>
<?php if ( isset( $post['visibility'] ) && 'private' === $post['visibility'] ) : ?>
<span class="fedistream-card__badge fedistream-card__badge--private">
<svg viewBox="0 0 24 24" fill="currentColor" width="12" height="12"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6z"/></svg>
</span>
<?php endif; ?>
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h3>
<?php if ( ! empty( $post['author'] ) ) : ?>
<p class="fedistream-card__author"><?php echo esc_html( $post['author'] ); ?></p>
<?php endif; ?>
<p class="fedistream-card__meta">
<?php if ( ! empty( $post['track_count'] ) ) : ?>
<span class="fedistream-card__tracks">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['track_count'], 'wp-fedistream' ) ),
(int) $post['track_count']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-card__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
</p>
</div>
</a>
</article>

View File

@@ -1,29 +0,0 @@
{# Playlist card partial #}
<article class="fedistream-card fedistream-card--playlist">
<a href="{{ post.permalink }}" class="fedistream-card__link">
<div class="fedistream-card__image fedistream-card__image--square">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" loading="lazy">
{% else %}
<div class="fedistream-card__placeholder fedistream-card__placeholder--playlist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</div>
{% endif %}
{% if post.visibility == 'private' %}
<span class="fedistream-card__badge fedistream-card__badge--private">
<svg viewBox="0 0 24 24" fill="currentColor" width="12" height="12"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>
</span>
{% endif %}
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title">{{ post.title }}</h3>
<p class="fedistream-card__author">{{ post.author }}</p>
<p class="fedistream-card__meta">
<span class="fedistream-card__count">{{ post.track_count }} {{ post.track_count == 1 ? 'track' : 'tracks' }}</span>
{% if post.duration_formatted %}
<span class="fedistream-card__duration">{{ post.duration_formatted }}</span>
{% endif %}
</p>
</div>
</a>
</article>

View File

@@ -0,0 +1,56 @@
<?php
/**
* Track card partial template.
*
* @package WP_FediStream
*
* @var array $post Track data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-card fedistream-card--track">
<a href="<?php echo esc_url( $post['permalink'] ?? '' ); ?>" class="fedistream-card__link">
<div class="fedistream-card__image fedistream-card__image--square">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" loading="lazy">
<?php elseif ( ! empty( $post['album_artwork'] ) ) : ?>
<img src="<?php echo esc_url( $post['album_artwork'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" loading="lazy">
<?php else : ?>
<div class="fedistream-card__placeholder fedistream-card__placeholder--track">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
<?php endif; ?>
<?php if ( ! empty( $post['explicit'] ) ) : ?>
<span class="fedistream-card__badge fedistream-card__badge--explicit">E</span>
<?php endif; ?>
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h3>
<?php if ( ! empty( $post['artists'] ) && is_array( $post['artists'] ) ) : ?>
<p class="fedistream-card__artist">
<?php
$artist_names = array_map(
function ( $artist ) {
return esc_html( $artist['name'] ?? '' );
},
$post['artists']
);
echo implode( ', ', $artist_names ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</p>
<?php endif; ?>
<p class="fedistream-card__meta">
<?php if ( ! empty( $post['album_title'] ) ) : ?>
<span class="fedistream-card__album"><?php echo esc_html( $post['album_title'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-card__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
</p>
</div>
</a>
</article>

View File

@@ -1,37 +0,0 @@
{# Track card partial #}
<article class="fedistream-card fedistream-card--track">
<a href="{{ post.permalink }}" class="fedistream-card__link">
<div class="fedistream-card__image fedistream-card__image--square">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" loading="lazy">
{% elseif post.album_artwork %}
<img src="{{ post.album_artwork }}" alt="{{ post.title|e('html_attr') }}" loading="lazy">
{% else %}
<div class="fedistream-card__placeholder fedistream-card__placeholder--track">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
{% endif %}
{% if post.explicit %}
<span class="fedistream-card__badge fedistream-card__badge--explicit">E</span>
{% endif %}
</div>
<div class="fedistream-card__content">
<h3 class="fedistream-card__title">{{ post.title }}</h3>
{% if post.artists %}
<p class="fedistream-card__artist">
{% for artist in post.artists %}
{{ artist.name }}{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
<p class="fedistream-card__meta">
{% if post.album_title %}
<span class="fedistream-card__album">{{ post.album_title }}</span>
{% endif %}
{% if post.duration_formatted %}
<span class="fedistream-card__duration">{{ post.duration_formatted }}</span>
{% endif %}
</p>
</div>
</a>
</article>

View File

@@ -0,0 +1,89 @@
<?php
/**
* Album shortcode template.
*
* @package WP_FediStream
*
* @var array $post Album data array.
* @var string $layout Layout style (full, card, compact).
* @var bool $show_tracks Whether to show tracks.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$layout = $layout ?? 'full';
?>
<div class="fedistream-shortcode fedistream-shortcode--album fedistream-shortcode--<?php echo esc_attr( $layout ); ?>">
<div class="fedistream-album">
<div class="fedistream-album__header">
<div class="fedistream-album__artwork">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-album__image">
<?php else : ?>
<div class="fedistream-album__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</div>
<?php endif; ?>
</div>
<div class="fedistream-album__info">
<?php if ( ! empty( $post['album_type_label'] ) ) : ?>
<span class="fedistream-album__type-badge"><?php echo esc_html( $post['album_type_label'] ); ?></span>
<?php endif; ?>
<h3 class="fedistream-album__title">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>"><?php echo esc_html( $post['title'] ?? '' ); ?></a>
</h3>
<?php if ( ! empty( $post['artist_name'] ) ) : ?>
<p class="fedistream-album__artist">
<a href="<?php echo esc_url( $post['artist_url'] ?? '#' ); ?>"><?php echo esc_html( $post['artist_name'] ); ?></a>
</p>
<?php endif; ?>
<div class="fedistream-album__meta">
<?php if ( ! empty( $post['release_date'] ) ) : ?>
<span class="fedistream-album__date"><?php echo esc_html( $post['release_date'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['total_tracks'] ) ) : ?>
<span class="fedistream-album__tracks">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['total_tracks'], 'wp-fedistream' ) ),
(int) $post['total_tracks']
);
?>
</span>
<?php endif; ?>
</div>
<div class="fedistream-album__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-album-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<?php esc_html_e( 'Play', 'wp-fedistream' ); ?>
</button>
</div>
</div>
</div>
<?php if ( ! empty( $show_tracks ) && ! empty( $post['tracks'] ) && is_array( $post['tracks'] ) ) : ?>
<div class="fedistream-album__tracklist">
<div class="fedistream-tracklist">
<?php foreach ( $post['tracks'] as $index => $track ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $track['track_number'] ?? ( $index + 1 ) ); ?></span>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $track['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></a>
</div>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
<button type="button" class="fedistream-tracklist__play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -1,64 +0,0 @@
{# Album shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--album fedistream-shortcode--{{ layout }}">
<div class="fedistream-album">
<div class="fedistream-album__header">
<div class="fedistream-album__artwork">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-album__image">
{% else %}
<div class="fedistream-album__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</div>
{% endif %}
</div>
<div class="fedistream-album__info">
{% if post.album_type %}
<span class="fedistream-album__type-badge">{{ post.album_type }}</span>
{% endif %}
<h3 class="fedistream-album__title">
<a href="{{ post.permalink }}">{{ post.title }}</a>
</h3>
{% if post.artist %}
<p class="fedistream-album__artist">
<a href="{{ post.artist_link }}">{{ post.artist }}</a>
</p>
{% endif %}
<div class="fedistream-album__meta">
{% if post.release_date %}
<span class="fedistream-album__date">{{ post.release_date }}</span>
{% endif %}
{% if post.track_count %}
<span class="fedistream-album__tracks">{{ post.track_count }} {{ post.track_count == 1 ? 'track' : 'tracks' }}</span>
{% endif %}
</div>
<div class="fedistream-album__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-album-id="{{ post.id }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
{{ __('Play', 'wp-fedistream') }}
</button>
</div>
</div>
</div>
{% if show_tracks and post.tracks is not empty %}
<div class="fedistream-album__tracklist">
<div class="fedistream-tracklist">
{% for track in post.tracks %}
<div class="fedistream-tracklist__item" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ track.track_number|default(loop.index) }}</span>
<div class="fedistream-tracklist__info">
<a href="{{ track.permalink }}" class="fedistream-tracklist__title">{{ track.title }}</a>
</div>
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
<button type="button" class="fedistream-tracklist__play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,84 @@
<?php
/**
* Artist shortcode template.
*
* @package WP_FediStream
*
* @var array $post Artist data array.
* @var string $layout Layout style (full, card, compact).
* @var bool $show_albums Whether to show albums.
* @var bool $show_tracks Whether to show tracks.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
$layout = $layout ?? 'full';
?>
<div class="fedistream-shortcode fedistream-shortcode--artist fedistream-shortcode--<?php echo esc_attr( $layout ); ?>">
<div class="fedistream-artist">
<div class="fedistream-artist__header">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-artist__image">
<?php else : ?>
<div class="fedistream-artist__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>
<?php endif; ?>
<div class="fedistream-artist__info">
<h3 class="fedistream-artist__name">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>"><?php echo esc_html( $post['title'] ?? '' ); ?></a>
</h3>
<?php if ( ! empty( $post['artist_type_label'] ) ) : ?>
<span class="fedistream-artist__type"><?php echo esc_html( $post['artist_type_label'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['genres'] ) && is_array( $post['genres'] ) ) : ?>
<div class="fedistream-artist__genres">
<?php foreach ( $post['genres'] as $genre ) : ?>
<a href="<?php echo esc_url( $genre['url'] ?? '#' ); ?>" class="fedistream-tag"><?php echo esc_html( $genre['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if ( 'full' === $layout && ! empty( $post['content'] ) ) : ?>
<div class="fedistream-artist__bio">
<?php echo wp_kses_post( $post['content'] ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $show_albums ) && ! empty( $post['albums'] ) && is_array( $post['albums'] ) ) : ?>
<div class="fedistream-artist__albums">
<h4 class="fedistream-section__title"><?php esc_html_e( 'Albums', 'wp-fedistream' ); ?></h4>
<div class="fedistream-grid fedistream-grid--small">
<?php foreach ( array_slice( $post['albums'], 0, 4 ) as $album ) : ?>
<?php echo $plugin->render_partial( 'partials/card-album', array( 'post' => $album ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ( ! empty( $show_tracks ) && ! empty( $post['tracks'] ) && is_array( $post['tracks'] ) ) : ?>
<div class="fedistream-artist__tracks">
<h4 class="fedistream-section__title"><?php esc_html_e( 'Popular Tracks', 'wp-fedistream' ); ?></h4>
<div class="fedistream-tracklist fedistream-tracklist--compact">
<?php foreach ( array_slice( $post['tracks'], 0, 5 ) as $index => $track ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $index + 1 ); ?></span>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $track['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></a>
</div>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -1,65 +0,0 @@
{# Artist shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--artist fedistream-shortcode--{{ layout }}">
<div class="fedistream-artist">
<div class="fedistream-artist__header">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-artist__image">
{% else %}
<div class="fedistream-artist__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>
{% endif %}
<div class="fedistream-artist__info">
<h3 class="fedistream-artist__name">
<a href="{{ post.permalink }}">{{ post.title }}</a>
</h3>
{% if post.artist_type %}
<span class="fedistream-artist__type">{{ post.artist_type }}</span>
{% endif %}
{% if post.genres is not empty %}
<div class="fedistream-artist__genres">
{% for genre in post.genres %}
<a href="{{ genre.link }}" class="fedistream-tag">{{ genre.name }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if layout == 'full' and post.content %}
<div class="fedistream-artist__bio">
{{ post.content|raw }}
</div>
{% endif %}
{% if show_albums and post.albums is not empty %}
<div class="fedistream-artist__albums">
<h4 class="fedistream-section__title">{{ __('Albums', 'wp-fedistream') }}</h4>
<div class="fedistream-grid fedistream-grid--small">
{% for album in post.albums|slice(0, 4) %}
{% include 'partials/card-album.twig' with { post: album } %}
{% endfor %}
</div>
</div>
{% endif %}
{% if show_tracks and post.tracks is not empty %}
<div class="fedistream-artist__tracks">
<h4 class="fedistream-section__title">{{ __('Popular Tracks', 'wp-fedistream') }}</h4>
<div class="fedistream-tracklist fedistream-tracklist--compact">
{% for track in post.tracks|slice(0, 5) %}
<div class="fedistream-tracklist__item" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ loop.index }}</span>
<div class="fedistream-tracklist__info">
<a href="{{ track.permalink }}" class="fedistream-tracklist__title">{{ track.title }}</a>
</div>
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,36 @@
<?php
/**
* Artists grid shortcode template.
*
* @package WP_FediStream
*
* @var array $posts Array of artist data.
* @var int $columns Number of columns.
* @var string $title Section title.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
$columns = $columns ?? 4;
?>
<div class="fedistream-shortcode fedistream-shortcode--artists">
<?php if ( ! empty( $title ) ) : ?>
<h3 class="fedistream-shortcode__title"><?php echo esc_html( $title ); ?></h3>
<?php endif; ?>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--artists fedistream-grid--cols-<?php echo esc_attr( $columns ); ?>">
<?php foreach ( $posts as $post ) : ?>
<?php echo $plugin->render_partial( 'partials/card-artist', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No artists found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,18 +0,0 @@
{# Artists grid shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--artists">
{% if title %}
<h3 class="fedistream-shortcode__title">{{ title }}</h3>
{% endif %}
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--artists fedistream-grid--cols-{{ columns }}">
{% for post in posts %}
{% include 'partials/card-artist.twig' with { post: post } %}
{% endfor %}
</div>
{% else %}
<div class="fedistream-empty">
<p>{{ __('No artists found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,127 @@
<?php
/**
* Player shortcode template.
*
* @package WP_FediStream
*
* @var array $tracks Array of track data.
* @var bool $autoplay Whether to autoplay.
* @var string $style Player style (default, compact, mini).
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$style = $style ?? 'default';
$autoplay = $autoplay ?? false;
if ( empty( $tracks ) || ! is_array( $tracks ) ) {
return;
}
$first_track = $tracks[0];
$is_playlist = count( $tracks ) > 1;
?>
<div class="fedistream-shortcode fedistream-shortcode--player fedistream-player-shortcode fedistream-player-shortcode--<?php echo esc_attr( $style ); ?>" data-autoplay="<?php echo esc_attr( $autoplay ? 'true' : 'false' ); ?>">
<div class="fedistream-player-shortcode__container">
<?php if ( 'mini' !== $style ) : ?>
<div class="fedistream-player-shortcode__artwork">
<?php if ( ! empty( $first_track['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $first_track['thumbnail'] ); ?>" alt="<?php echo esc_attr( $first_track['title'] ?? '' ); ?>" class="fedistream-player-shortcode__image">
<?php else : ?>
<div class="fedistream-player-shortcode__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="fedistream-player-shortcode__info">
<h4 class="fedistream-player-shortcode__title"><?php echo esc_html( $first_track['title'] ?? '' ); ?></h4>
<?php if ( ! empty( $first_track['artists'] ) && is_array( $first_track['artists'] ) ) : ?>
<p class="fedistream-player-shortcode__artist">
<?php
$artist_names = array_map(
function ( $artist ) {
return esc_html( $artist['name'] ?? '' );
},
$first_track['artists']
);
echo implode( ', ', $artist_names ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</p>
<?php endif; ?>
</div>
<div class="fedistream-player" data-track-id="<?php echo esc_attr( $first_track['id'] ?? '' ); ?>" data-audio-url="<?php echo esc_url( $first_track['audio_url'] ?? '' ); ?>">
<div class="fedistream-player__controls">
<?php if ( $is_playlist ) : ?>
<button type="button" class="fedistream-player__btn fedistream-player__btn--prev" aria-label="<?php esc_attr_e( 'Previous', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<?php endif; ?>
<button type="button" class="fedistream-player__btn fedistream-player__btn--play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<?php if ( $is_playlist ) : ?>
<button type="button" class="fedistream-player__btn fedistream-player__btn--next" aria-label="<?php esc_attr_e( 'Next', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<?php endif; ?>
</div>
<div class="fedistream-player__progress">
<span class="fedistream-player__time fedistream-player__time--current">0:00</span>
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="<?php esc_attr_e( 'Seek', 'wp-fedistream' ); ?>">
</div>
<span class="fedistream-player__time fedistream-player__time--total"><?php echo esc_html( $first_track['duration_formatted'] ?? '0:00' ); ?></span>
</div>
<div class="fedistream-player__volume">
<button type="button" class="fedistream-player__btn fedistream-player__btn--volume" aria-label="<?php esc_attr_e( 'Volume', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
<input type="range" class="fedistream-player__volume-slider" min="0" max="100" value="80" aria-label="<?php esc_attr_e( 'Volume', 'wp-fedistream' ); ?>">
</div>
</div>
</div>
<?php if ( $is_playlist ) : ?>
<div class="fedistream-player-shortcode__playlist" data-tracks='<?php echo esc_attr( wp_json_encode( array_map( function ( $track ) {
return array(
'id' => $track['id'] ?? 0,
'title' => $track['title'] ?? '',
'artist' => ! empty( $track['artists'] ) ? implode( ', ', array_column( $track['artists'], 'name' ) ) : '',
'audio' => $track['audio_url'] ?? '',
'duration' => $track['duration_formatted'] ?? '0:00',
'artwork' => $track['thumbnail'] ?? '',
);
}, $tracks ) ) ); ?>'>
<div class="fedistream-tracklist fedistream-tracklist--player">
<?php foreach ( $tracks as $index => $track ) : ?>
<div class="fedistream-tracklist__item<?php echo 0 === $index ? ' fedistream-tracklist__item--active' : ''; ?>" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>" data-index="<?php echo esc_attr( $index ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $index + 1 ); ?></span>
<div class="fedistream-tracklist__info">
<span class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></span>
<?php if ( ! empty( $track['artists'] ) && is_array( $track['artists'] ) ) : ?>
<span class="fedistream-tracklist__artist">
<?php echo esc_html( implode( ', ', array_column( $track['artists'], 'name' ) ) ); ?>
</span>
<?php endif; ?>
</div>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -1,108 +0,0 @@
{# Audio player shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--player fedistream-player-widget fedistream-player-widget--{{ style }}" data-autoplay="{{ autoplay ? 'true' : 'false' }}">
{% if tracks|length == 1 %}
{# Single track player #}
{% set track = tracks[0] %}
<div class="fedistream-player fedistream-player--single" data-track-id="{{ track.id }}" data-audio-url="{{ track.audio_url }}">
<div class="fedistream-player__track-info">
{% if track.thumbnail %}
<img src="{{ track.thumbnail }}" alt="{{ track.title|e('html_attr') }}" class="fedistream-player__artwork">
{% endif %}
<div class="fedistream-player__details">
<span class="fedistream-player__title">{{ track.title }}</span>
{% if track.artist %}
<span class="fedistream-player__artist">{{ track.artist }}</span>
{% endif %}
</div>
</div>
<div class="fedistream-player__controls">
<button type="button" class="fedistream-player__btn fedistream-player__btn--play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
</div>
<div class="fedistream-player__progress">
<span class="fedistream-player__time fedistream-player__time--current">0:00</span>
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="{{ __('Seek', 'wp-fedistream') }}">
</div>
<span class="fedistream-player__time fedistream-player__time--total">{{ track.duration_formatted|default('0:00') }}</span>
</div>
<div class="fedistream-player__volume">
<button type="button" class="fedistream-player__btn fedistream-player__btn--volume" aria-label="{{ __('Volume', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
<input type="range" class="fedistream-player__volume-slider" min="0" max="100" value="80" aria-label="{{ __('Volume', 'wp-fedistream') }}">
</div>
</div>
{% else %}
{# Multi-track player (playlist/album) #}
<div class="fedistream-player fedistream-player--multi" data-tracks="{{ tracks|json_encode|e('html_attr') }}">
<div class="fedistream-player__now-playing">
<div class="fedistream-player__artwork-wrapper">
<img src="" alt="" class="fedistream-player__artwork fedistream-player__artwork--current">
</div>
<div class="fedistream-player__details">
<span class="fedistream-player__title fedistream-player__title--current"></span>
<span class="fedistream-player__artist fedistream-player__artist--current"></span>
</div>
</div>
<div class="fedistream-player__main-controls">
<button type="button" class="fedistream-player__btn fedistream-player__btn--prev" aria-label="{{ __('Previous', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button type="button" class="fedistream-player__btn fedistream-player__btn--play fedistream-player__btn--play-main" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<button type="button" class="fedistream-player__btn fedistream-player__btn--next" aria-label="{{ __('Next', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
</div>
<div class="fedistream-player__progress">
<span class="fedistream-player__time fedistream-player__time--current">0:00</span>
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="{{ __('Seek', 'wp-fedistream') }}">
</div>
<span class="fedistream-player__time fedistream-player__time--total">0:00</span>
</div>
<div class="fedistream-player__secondary-controls">
<button type="button" class="fedistream-player__btn fedistream-player__btn--shuffle" aria-label="{{ __('Shuffle', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
</button>
<button type="button" class="fedistream-player__btn fedistream-player__btn--repeat" aria-label="{{ __('Repeat', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
</button>
<div class="fedistream-player__volume">
<button type="button" class="fedistream-player__btn fedistream-player__btn--volume" aria-label="{{ __('Volume', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
<input type="range" class="fedistream-player__volume-slider" min="0" max="100" value="80" aria-label="{{ __('Volume', 'wp-fedistream') }}">
</div>
</div>
</div>
{# Track list #}
<div class="fedistream-player__queue">
<div class="fedistream-tracklist fedistream-tracklist--queue">
{% for track in tracks %}
<div class="fedistream-tracklist__item" data-track-index="{{ loop.index0 }}" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ loop.index }}</span>
{% if track.thumbnail %}
<img src="{{ track.thumbnail }}" alt="" class="fedistream-tracklist__artwork">
{% endif %}
<div class="fedistream-tracklist__info">
<span class="fedistream-tracklist__title">{{ track.title }}</span>
<span class="fedistream-tracklist__artist">{{ track.artist }}</span>
</div>
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,96 @@
<?php
/**
* Playlist shortcode template.
*
* @package WP_FediStream
*
* @var array $post Playlist data array.
* @var string $layout Layout style (full, card, compact).
* @var bool $show_tracks Whether to show tracks.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$layout = $layout ?? 'full';
?>
<div class="fedistream-shortcode fedistream-shortcode--playlist fedistream-shortcode--<?php echo esc_attr( $layout ); ?>">
<div class="fedistream-playlist">
<div class="fedistream-playlist__header">
<div class="fedistream-playlist__artwork">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-playlist__image">
<?php else : ?>
<div class="fedistream-playlist__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</div>
<?php endif; ?>
<?php if ( isset( $post['visibility'] ) && 'private' === $post['visibility'] ) : ?>
<span class="fedistream-playlist__badge fedistream-playlist__badge--private">
<svg viewBox="0 0 24 24" fill="currentColor" width="12" height="12"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>
</span>
<?php endif; ?>
</div>
<div class="fedistream-playlist__info">
<span class="fedistream-playlist__type-badge"><?php esc_html_e( 'Playlist', 'wp-fedistream' ); ?></span>
<h3 class="fedistream-playlist__title">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>"><?php echo esc_html( $post['title'] ?? '' ); ?></a>
</h3>
<?php if ( ! empty( $post['author'] ) ) : ?>
<p class="fedistream-playlist__author">
<?php esc_html_e( 'by', 'wp-fedistream' ); ?> <?php echo esc_html( $post['author'] ); ?>
</p>
<?php endif; ?>
<div class="fedistream-playlist__meta">
<?php if ( ! empty( $post['track_count'] ) ) : ?>
<span class="fedistream-playlist__count">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['track_count'], 'wp-fedistream' ) ),
(int) $post['track_count']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-playlist__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
</div>
<div class="fedistream-playlist__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-playlist-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<?php esc_html_e( 'Play', 'wp-fedistream' ); ?>
</button>
</div>
</div>
</div>
<?php if ( ! empty( $show_tracks ) && ! empty( $post['tracks'] ) && is_array( $post['tracks'] ) ) : ?>
<div class="fedistream-playlist__tracklist">
<div class="fedistream-tracklist">
<?php foreach ( $post['tracks'] as $index => $track ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $index + 1 ); ?></span>
<?php if ( ! empty( $track['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $track['thumbnail'] ); ?>" alt="" class="fedistream-tracklist__artwork">
<?php endif; ?>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $track['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></a>
<span class="fedistream-tracklist__artist"><?php echo esc_html( $track['artist'] ?? '' ); ?></span>
</div>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
<button type="button" class="fedistream-tracklist__play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -1,71 +0,0 @@
{# Playlist shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--playlist fedistream-shortcode--{{ layout }}">
<div class="fedistream-playlist">
<div class="fedistream-playlist__header">
<div class="fedistream-playlist__artwork">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-playlist__image">
{% else %}
<div class="fedistream-playlist__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</div>
{% endif %}
{% if post.visibility == 'private' %}
<span class="fedistream-playlist__badge fedistream-playlist__badge--private">
<svg viewBox="0 0 24 24" fill="currentColor" width="12" height="12"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>
</span>
{% endif %}
</div>
<div class="fedistream-playlist__info">
<span class="fedistream-playlist__type-badge">{{ __('Playlist', 'wp-fedistream') }}</span>
<h3 class="fedistream-playlist__title">
<a href="{{ post.permalink }}">{{ post.title }}</a>
</h3>
{% if post.author %}
<p class="fedistream-playlist__author">
{{ __('by', 'wp-fedistream') }} {{ post.author }}
</p>
{% endif %}
<div class="fedistream-playlist__meta">
{% if post.track_count %}
<span class="fedistream-playlist__count">{{ post.track_count }} {{ post.track_count == 1 ? 'track' : 'tracks' }}</span>
{% endif %}
{% if post.duration_formatted %}
<span class="fedistream-playlist__duration">{{ post.duration_formatted }}</span>
{% endif %}
</div>
<div class="fedistream-playlist__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-playlist-id="{{ post.id }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
{{ __('Play', 'wp-fedistream') }}
</button>
</div>
</div>
</div>
{% if show_tracks and post.tracks is not empty %}
<div class="fedistream-playlist__tracklist">
<div class="fedistream-tracklist">
{% for track in post.tracks %}
<div class="fedistream-tracklist__item" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ loop.index }}</span>
{% if track.thumbnail %}
<img src="{{ track.thumbnail }}" alt="" class="fedistream-tracklist__artwork">
{% endif %}
<div class="fedistream-tracklist__info">
<a href="{{ track.permalink }}" class="fedistream-tracklist__title">{{ track.title }}</a>
<span class="fedistream-tracklist__artist">{{ track.artist }}</span>
</div>
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
<button type="button" class="fedistream-tracklist__play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,36 @@
<?php
/**
* Releases grid shortcode template.
*
* @package WP_FediStream
*
* @var array $posts Array of album data.
* @var int $columns Number of columns.
* @var string $title Section title.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
$columns = $columns ?? 3;
?>
<div class="fedistream-shortcode fedistream-shortcode--releases">
<?php if ( ! empty( $title ) ) : ?>
<h3 class="fedistream-shortcode__title"><?php echo esc_html( $title ); ?></h3>
<?php endif; ?>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-grid fedistream-grid--albums fedistream-grid--cols-<?php echo esc_attr( $columns ); ?>">
<?php foreach ( $posts as $post ) : ?>
<?php echo $plugin->render_partial( 'partials/card-album', array( 'post' => $post ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No releases found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,18 +0,0 @@
{# Latest releases grid shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--releases">
{% if title %}
<h3 class="fedistream-shortcode__title">{{ title }}</h3>
{% endif %}
{% if posts is not empty %}
<div class="fedistream-grid fedistream-grid--albums fedistream-grid--cols-{{ columns }}">
{% for post in posts %}
{% include 'partials/card-album.twig' with { post: post } %}
{% endfor %}
</div>
{% else %}
<div class="fedistream-empty">
<p>{{ __('No releases found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,97 @@
<?php
/**
* Track shortcode template.
*
* @package WP_FediStream
*
* @var array $post Track data array.
* @var string $layout Layout style (full, card, compact).
* @var bool $show_player Whether to show the player.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$layout = $layout ?? 'full';
?>
<div class="fedistream-shortcode fedistream-shortcode--track fedistream-shortcode--<?php echo esc_attr( $layout ); ?>">
<div class="fedistream-track">
<div class="fedistream-track__header">
<div class="fedistream-track__artwork">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-track__image">
<?php else : ?>
<div class="fedistream-track__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
<?php endif; ?>
<?php if ( ! empty( $show_player ) ) : ?>
<button type="button" class="fedistream-track__play-overlay" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<?php endif; ?>
</div>
<div class="fedistream-track__info">
<h3 class="fedistream-track__title">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>"><?php echo esc_html( $post['title'] ?? '' ); ?></a>
</h3>
<?php if ( ! empty( $post['artists'] ) && is_array( $post['artists'] ) ) : ?>
<p class="fedistream-track__artists">
<?php
$artist_links = array();
foreach ( $post['artists'] as $artist ) {
$artist_links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $artist['url'] ?? '#' ),
esc_html( $artist['name'] ?? '' )
);
}
echo implode( ', ', $artist_links ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</p>
<?php endif; ?>
<?php if ( ! empty( $post['album_title'] ) ) : ?>
<p class="fedistream-track__album">
<?php esc_html_e( 'From', 'wp-fedistream' ); ?> <a href="<?php echo esc_url( $post['album_url'] ?? '#' ); ?>"><?php echo esc_html( $post['album_title'] ); ?></a>
</p>
<?php endif; ?>
<div class="fedistream-track__meta">
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-track__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['play_count'] ) ) : ?>
<span class="fedistream-track__plays">
<?php
printf(
/* translators: %d: number of plays */
esc_html( _n( '%d play', '%d plays', $post['play_count'], 'wp-fedistream' ) ),
(int) $post['play_count']
);
?>
</span>
<?php endif; ?>
</div>
</div>
</div>
<?php if ( ! empty( $show_player ) && ! empty( $post['audio_url'] ) ) : ?>
<div class="fedistream-track__player">
<div class="fedistream-player fedistream-player--inline" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>" data-audio-url="<?php echo esc_url( $post['audio_url'] ); ?>">
<button type="button" class="fedistream-player__btn fedistream-player__btn--play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<div class="fedistream-player__progress">
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="<?php esc_attr_e( 'Seek', 'wp-fedistream' ); ?>">
</div>
</div>
<span class="fedistream-player__time"><?php echo esc_html( $post['duration_formatted'] ?? '0:00' ); ?></span>
</div>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -1,64 +0,0 @@
{# Track shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--track fedistream-shortcode--{{ layout }}">
<div class="fedistream-track">
<div class="fedistream-track__header">
<div class="fedistream-track__artwork">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-track__image">
{% else %}
<div class="fedistream-track__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
{% endif %}
{% if show_player %}
<button type="button" class="fedistream-track__play-overlay" data-track-id="{{ post.id }}" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
{% endif %}
</div>
<div class="fedistream-track__info">
<h3 class="fedistream-track__title">
<a href="{{ post.permalink }}">{{ post.title }}</a>
</h3>
{% if post.artists is not empty %}
<p class="fedistream-track__artists">
{% for artist in post.artists %}
<a href="{{ artist.link }}">{{ artist.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
{% if post.album %}
<p class="fedistream-track__album">
{{ __('From', 'wp-fedistream') }} <a href="{{ post.album_link }}">{{ post.album }}</a>
</p>
{% endif %}
<div class="fedistream-track__meta">
{% if post.duration_formatted %}
<span class="fedistream-track__duration">{{ post.duration_formatted }}</span>
{% endif %}
{% if post.play_count %}
<span class="fedistream-track__plays">{{ post.play_count }} {{ post.play_count == 1 ? 'play' : 'plays' }}</span>
{% endif %}
</div>
</div>
</div>
{% if show_player and post.audio_url %}
<div class="fedistream-track__player">
<div class="fedistream-player fedistream-player--inline" data-track-id="{{ post.id }}" data-audio-url="{{ post.audio_url }}">
<button type="button" class="fedistream-player__btn fedistream-player__btn--play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<div class="fedistream-player__progress">
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="{{ __('Seek', 'wp-fedistream') }}">
</div>
</div>
<span class="fedistream-player__time">{{ post.duration_formatted|default('0:00') }}</span>
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,73 @@
<?php
/**
* Tracks list shortcode template.
*
* @package WP_FediStream
*
* @var array $posts Array of track data.
* @var int $columns Number of columns.
* @var string $title Section title.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$columns = $columns ?? 1;
?>
<div class="fedistream-shortcode fedistream-shortcode--tracks">
<?php if ( ! empty( $title ) ) : ?>
<h3 class="fedistream-shortcode__title"><?php echo esc_html( $title ); ?></h3>
<?php endif; ?>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<div class="fedistream-tracklist fedistream-tracklist--numbered">
<?php foreach ( $posts as $index => $post ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__rank"><?php echo esc_html( $index + 1 ); ?></span>
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="" class="fedistream-tracklist__artwork">
<?php else : ?>
<div class="fedistream-tracklist__artwork fedistream-tracklist__artwork--placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
<?php endif; ?>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $post['title'] ?? '' ); ?></a>
<span class="fedistream-tracklist__artist">
<?php if ( ! empty( $post['artists'] ) && is_array( $post['artists'] ) ) : ?>
<?php
$artist_links = array();
foreach ( $post['artists'] as $artist ) {
$artist_links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $artist['url'] ?? '#' ),
esc_html( $artist['name'] ?? '' )
);
}
echo implode( ', ', $artist_links ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<?php elseif ( ! empty( $post['artist'] ) ) : ?>
<?php echo esc_html( $post['artist'] ); ?>
<?php endif; ?>
</span>
</div>
<?php if ( ! empty( $post['play_count'] ) ) : ?>
<span class="fedistream-tracklist__plays"><?php echo esc_html( number_format( $post['play_count'] ) ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
<button type="button" class="fedistream-tracklist__play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
<?php else : ?>
<div class="fedistream-empty">
<p><?php esc_html_e( 'No tracks found.', 'wp-fedistream' ); ?></p>
</div>
<?php endif; ?>
</div>

View File

@@ -1,48 +0,0 @@
{# Popular tracks list shortcode template #}
<div class="fedistream-shortcode fedistream-shortcode--tracks">
{% if title %}
<h3 class="fedistream-shortcode__title">{{ title }}</h3>
{% endif %}
{% if posts is not empty %}
<div class="fedistream-tracklist fedistream-tracklist--numbered">
{% for post in posts %}
<div class="fedistream-tracklist__item" data-track-id="{{ post.id }}">
<span class="fedistream-tracklist__rank">{{ loop.index }}</span>
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="" class="fedistream-tracklist__artwork">
{% else %}
<div class="fedistream-tracklist__artwork fedistream-tracklist__artwork--placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
{% endif %}
<div class="fedistream-tracklist__info">
<a href="{{ post.permalink }}" class="fedistream-tracklist__title">{{ post.title }}</a>
<span class="fedistream-tracklist__artist">
{% if post.artists is iterable %}
{% for artist in post.artists %}
<a href="{{ artist.link }}">{{ artist.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
{{ post.artist }}
{% endif %}
</span>
</div>
{% if post.play_count %}
<span class="fedistream-tracklist__plays">{{ post.play_count|number_format }}</span>
{% endif %}
{% if post.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ post.duration_formatted }}</span>
{% endif %}
<button type="button" class="fedistream-tracklist__play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
{% endfor %}
</div>
{% else %}
<div class="fedistream-empty">
<p>{{ __('No tracks found.', 'wp-fedistream') }}</p>
</div>
{% endif %}
</div>

126
templates/single/album.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
/**
* Single album template.
*
* @package WP_FediStream
*
* @var array $post Album data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-single fedistream-single--album">
<header class="fedistream-single__header fedistream-single__header--album">
<div class="fedistream-single__artwork">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-single__image fedistream-single__image--album">
<?php else : ?>
<div class="fedistream-single__placeholder fedistream-single__placeholder--album">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</div>
<?php endif; ?>
</div>
<div class="fedistream-single__info">
<span class="fedistream-single__type-badge"><?php echo esc_html( $post['album_type_label'] ?? __( 'Album', 'wp-fedistream' ) ); ?></span>
<h1 class="fedistream-single__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h1>
<?php if ( ! empty( $post['artist_name'] ) ) : ?>
<p class="fedistream-single__artist">
<a href="<?php echo esc_url( $post['artist_url'] ?? '#' ); ?>"><?php echo esc_html( $post['artist_name'] ); ?></a>
</p>
<?php endif; ?>
<div class="fedistream-single__meta">
<?php if ( ! empty( $post['release_date'] ) ) : ?>
<span class="fedistream-single__date"><?php echo esc_html( $post['release_date'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['total_tracks'] ) ) : ?>
<span class="fedistream-single__tracks">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['total_tracks'], 'wp-fedistream' ) ),
(int) $post['total_tracks']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-single__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
</div>
<?php if ( ! empty( $post['genres'] ) && is_array( $post['genres'] ) ) : ?>
<div class="fedistream-single__genres">
<?php foreach ( $post['genres'] as $genre ) : ?>
<a href="<?php echo esc_url( $genre['url'] ?? '#' ); ?>" class="fedistream-tag"><?php echo esc_html( $genre['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="fedistream-single__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-album-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<?php esc_html_e( 'Play All', 'wp-fedistream' ); ?>
</button>
<button type="button" class="fedistream-btn fedistream-btn--secondary fedistream-btn--shuffle" data-album-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
<?php esc_html_e( 'Shuffle', 'wp-fedistream' ); ?>
</button>
</div>
</div>
</header>
<?php if ( ! empty( $post['tracks'] ) && is_array( $post['tracks'] ) ) : ?>
<section class="fedistream-single__tracklist">
<div class="fedistream-tracklist fedistream-tracklist--album">
<?php foreach ( $post['tracks'] as $index => $track ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $track['track_number'] ?? ( $index + 1 ) ); ?></span>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $track['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></a>
<?php if ( ! empty( $track['featured_artists'] ) ) : ?>
<span class="fedistream-tracklist__featuring"><?php esc_html_e( 'feat.', 'wp-fedistream' ); ?> <?php echo esc_html( $track['featured_artists'] ); ?></span>
<?php endif; ?>
</div>
<?php if ( ! empty( $track['explicit'] ) ) : ?>
<span class="fedistream-badge fedistream-badge--explicit">E</span>
<?php endif; ?>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
<button type="button" class="fedistream-tracklist__play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['content'] ) ) : ?>
<section class="fedistream-single__content">
<h2 class="fedistream-section__title"><?php esc_html_e( 'About This Album', 'wp-fedistream' ); ?></h2>
<div class="fedistream-single__description">
<?php echo wp_kses_post( $post['content'] ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['credits'] ) ) : ?>
<section class="fedistream-single__credits">
<h2 class="fedistream-section__title"><?php esc_html_e( 'Credits', 'wp-fedistream' ); ?></h2>
<div class="fedistream-credits">
<?php echo wp_kses_post( $post['credits'] ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['license'] ) ) : ?>
<section class="fedistream-single__license">
<p class="fedistream-license">
<strong><?php esc_html_e( 'License:', 'wp-fedistream' ); ?></strong>
<a href="<?php echo esc_url( $post['license']['link'] ?? '#' ); ?>"><?php echo esc_html( $post['license']['name'] ?? '' ); ?></a>
</p>
</section>
<?php endif; ?>
</article>

View File

@@ -1,105 +0,0 @@
{# Single album template #}
<article class="fedistream-single fedistream-single--album">
<header class="fedistream-single__header fedistream-single__header--album">
<div class="fedistream-single__artwork">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-single__image fedistream-single__image--album">
{% else %}
<div class="fedistream-single__placeholder fedistream-single__placeholder--album">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</div>
{% endif %}
</div>
<div class="fedistream-single__info">
<span class="fedistream-single__type-badge">{{ post.album_type|default('Album') }}</span>
<h1 class="fedistream-single__title">{{ post.title }}</h1>
{% if post.artist %}
<p class="fedistream-single__artist">
<a href="{{ post.artist_link }}">{{ post.artist }}</a>
</p>
{% endif %}
<div class="fedistream-single__meta">
{% if post.release_date %}
<span class="fedistream-single__date">{{ post.release_date }}</span>
{% endif %}
{% if post.track_count %}
<span class="fedistream-single__tracks">{{ post.track_count }} {{ post.track_count == 1 ? __('track', 'wp-fedistream') : __('tracks', 'wp-fedistream') }}</span>
{% endif %}
{% if post.duration_formatted %}
<span class="fedistream-single__duration">{{ post.duration_formatted }}</span>
{% endif %}
</div>
{% if post.genres is not empty %}
<div class="fedistream-single__genres">
{% for genre in post.genres %}
<a href="{{ genre.link }}" class="fedistream-tag">{{ genre.name }}</a>
{% endfor %}
</div>
{% endif %}
<div class="fedistream-single__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-album-id="{{ post.id }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
{{ __('Play All', 'wp-fedistream') }}
</button>
<button type="button" class="fedistream-btn fedistream-btn--secondary fedistream-btn--shuffle" data-album-id="{{ post.id }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
{{ __('Shuffle', 'wp-fedistream') }}
</button>
</div>
</div>
</header>
{% if post.tracks is not empty %}
<section class="fedistream-single__tracklist">
<div class="fedistream-tracklist fedistream-tracklist--album">
{% for track in post.tracks %}
<div class="fedistream-tracklist__item" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ track.track_number|default(loop.index) }}</span>
<div class="fedistream-tracklist__info">
<a href="{{ track.permalink }}" class="fedistream-tracklist__title">{{ track.title }}</a>
{% if track.featured_artists %}
<span class="fedistream-tracklist__featuring">{{ __('feat.', 'wp-fedistream') }} {{ track.featured_artists }}</span>
{% endif %}
</div>
{% if track.explicit %}
<span class="fedistream-badge fedistream-badge--explicit">E</span>
{% endif %}
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
<button type="button" class="fedistream-tracklist__play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if post.content %}
<section class="fedistream-single__content">
<h2 class="fedistream-section__title">{{ __('About This Album', 'wp-fedistream') }}</h2>
<div class="fedistream-single__description">
{{ post.content|raw }}
</div>
</section>
{% endif %}
{% if post.credits %}
<section class="fedistream-single__credits">
<h2 class="fedistream-section__title">{{ __('Credits', 'wp-fedistream') }}</h2>
<div class="fedistream-credits">
{{ post.credits|raw }}
</div>
</section>
{% endif %}
{% if post.license %}
<section class="fedistream-single__license">
<p class="fedistream-license">
<strong>{{ __('License:', 'wp-fedistream') }}</strong>
<a href="{{ post.license.link }}">{{ post.license.name }}</a>
</p>
</section>
{% endif %}
</article>

103
templates/single/artist.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
/**
* Single artist template.
*
* @package WP_FediStream
*
* @var array $post Artist data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$plugin = \WP_FediStream\Plugin::get_instance();
?>
<article class="fedistream-single fedistream-single--artist">
<header class="fedistream-single__header">
<div class="fedistream-single__hero">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-single__image fedistream-single__image--artist">
<?php else : ?>
<div class="fedistream-single__placeholder fedistream-single__placeholder--artist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>
<?php endif; ?>
</div>
<div class="fedistream-single__info">
<h1 class="fedistream-single__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h1>
<?php if ( ! empty( $post['artist_type_label'] ) ) : ?>
<p class="fedistream-single__type"><?php echo esc_html( $post['artist_type_label'] ); ?></p>
<?php endif; ?>
<?php if ( ! empty( $post['genres'] ) && is_array( $post['genres'] ) ) : ?>
<div class="fedistream-single__genres">
<?php foreach ( $post['genres'] as $genre ) : ?>
<a href="<?php echo esc_url( $genre['url'] ?? '#' ); ?>" class="fedistream-tag"><?php echo esc_html( $genre['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</header>
<?php if ( ! empty( $post['content'] ) ) : ?>
<section class="fedistream-single__content">
<h2 class="fedistream-section__title"><?php esc_html_e( 'About', 'wp-fedistream' ); ?></h2>
<div class="fedistream-single__description">
<?php echo wp_kses_post( $post['content'] ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['social_links'] ) && is_array( $post['social_links'] ) ) : ?>
<section class="fedistream-single__social">
<h2 class="fedistream-section__title"><?php esc_html_e( 'Connect', 'wp-fedistream' ); ?></h2>
<div class="fedistream-social-links">
<?php foreach ( $post['social_links'] as $platform => $url ) : ?>
<a href="<?php echo esc_url( $url ); ?>" class="fedistream-social-link fedistream-social-link--<?php echo esc_attr( $platform ); ?>" target="_blank" rel="noopener noreferrer">
<?php echo esc_html( $platform ); ?>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['albums'] ) && is_array( $post['albums'] ) ) : ?>
<section class="fedistream-single__albums">
<h2 class="fedistream-section__title"><?php esc_html_e( 'Discography', 'wp-fedistream' ); ?></h2>
<div class="fedistream-grid fedistream-grid--albums">
<?php foreach ( $post['albums'] as $album ) : ?>
<?php echo $plugin->render_partial( 'partials/card-album', array( 'post' => $album ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['tracks'] ) && is_array( $post['tracks'] ) ) : ?>
<section class="fedistream-single__tracks">
<h2 class="fedistream-section__title"><?php esc_html_e( 'Popular Tracks', 'wp-fedistream' ); ?></h2>
<div class="fedistream-tracklist">
<?php foreach ( $post['tracks'] as $index => $track ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $index + 1 ); ?></span>
<?php if ( ! empty( $track['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $track['thumbnail'] ); ?>" alt="" class="fedistream-tracklist__artwork">
<?php endif; ?>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $track['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></a>
<?php if ( ! empty( $track['album_title'] ) ) : ?>
<span class="fedistream-tracklist__album"><?php echo esc_html( $track['album_title'] ); ?></span>
<?php endif; ?>
</div>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
<button type="button" class="fedistream-tracklist__play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
</article>

View File

@@ -1,88 +0,0 @@
{# Single artist template #}
<article class="fedistream-single fedistream-single--artist">
<header class="fedistream-single__header">
<div class="fedistream-single__hero">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-single__image fedistream-single__image--artist">
{% else %}
<div class="fedistream-single__placeholder fedistream-single__placeholder--artist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</div>
{% endif %}
</div>
<div class="fedistream-single__info">
<h1 class="fedistream-single__title">{{ post.title }}</h1>
{% if post.artist_type %}
<p class="fedistream-single__type">{{ post.artist_type }}</p>
{% endif %}
{% if post.genres is not empty %}
<div class="fedistream-single__genres">
{% for genre in post.genres %}
<a href="{{ genre.link }}" class="fedistream-tag">{{ genre.name }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</header>
{% if post.content %}
<section class="fedistream-single__content">
<h2 class="fedistream-section__title">{{ __('About', 'wp-fedistream') }}</h2>
<div class="fedistream-single__description">
{{ post.content|raw }}
</div>
</section>
{% endif %}
{% if post.social_links is not empty %}
<section class="fedistream-single__social">
<h2 class="fedistream-section__title">{{ __('Connect', 'wp-fedistream') }}</h2>
<div class="fedistream-social-links">
{% for platform, url in post.social_links %}
<a href="{{ url }}" class="fedistream-social-link fedistream-social-link--{{ platform }}" target="_blank" rel="noopener noreferrer">
{{ platform }}
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% if post.albums is not empty %}
<section class="fedistream-single__albums">
<h2 class="fedistream-section__title">{{ __('Discography', 'wp-fedistream') }}</h2>
<div class="fedistream-grid fedistream-grid--albums">
{% for album in post.albums %}
{% include 'partials/card-album.twig' with { post: album } %}
{% endfor %}
</div>
</section>
{% endif %}
{% if post.tracks is not empty %}
<section class="fedistream-single__tracks">
<h2 class="fedistream-section__title">{{ __('Popular Tracks', 'wp-fedistream') }}</h2>
<div class="fedistream-tracklist">
{% for track in post.tracks %}
<div class="fedistream-tracklist__item" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ loop.index }}</span>
{% if track.thumbnail %}
<img src="{{ track.thumbnail }}" alt="" class="fedistream-tracklist__artwork">
{% endif %}
<div class="fedistream-tracklist__info">
<a href="{{ track.permalink }}" class="fedistream-tracklist__title">{{ track.title }}</a>
{% if track.album %}
<span class="fedistream-tracklist__album">{{ track.album }}</span>
{% endif %}
</div>
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
<button type="button" class="fedistream-tracklist__play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
{% endfor %}
</div>
</section>
{% endif %}
</article>

View File

@@ -0,0 +1,136 @@
<?php
/**
* Single playlist template.
*
* @package WP_FediStream
*
* @var array $post Playlist data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-single fedistream-single--playlist">
<header class="fedistream-single__header fedistream-single__header--playlist">
<div class="fedistream-single__artwork">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-single__image fedistream-single__image--playlist">
<?php else : ?>
<div class="fedistream-single__placeholder fedistream-single__placeholder--playlist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</div>
<?php endif; ?>
<?php if ( isset( $post['visibility'] ) && 'private' === $post['visibility'] ) : ?>
<span class="fedistream-single__badge fedistream-single__badge--private">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>
<?php esc_html_e( 'Private', 'wp-fedistream' ); ?>
</span>
<?php endif; ?>
</div>
<div class="fedistream-single__info">
<span class="fedistream-single__type-badge"><?php esc_html_e( 'Playlist', 'wp-fedistream' ); ?></span>
<h1 class="fedistream-single__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h1>
<?php if ( ! empty( $post['author'] ) ) : ?>
<p class="fedistream-single__author">
<?php esc_html_e( 'Created by', 'wp-fedistream' ); ?> <a href="<?php echo esc_url( $post['author_link'] ?? '#' ); ?>"><?php echo esc_html( $post['author'] ); ?></a>
</p>
<?php endif; ?>
<div class="fedistream-single__meta">
<?php if ( ! empty( $post['track_count'] ) ) : ?>
<span class="fedistream-single__tracks">
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['track_count'], 'wp-fedistream' ) ),
(int) $post['track_count']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-single__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['updated_date'] ) ) : ?>
<span class="fedistream-single__updated"><?php esc_html_e( 'Updated', 'wp-fedistream' ); ?> <?php echo esc_html( $post['updated_date'] ); ?></span>
<?php endif; ?>
</div>
<?php if ( ! empty( $post['moods'] ) && is_array( $post['moods'] ) ) : ?>
<div class="fedistream-single__moods">
<?php foreach ( $post['moods'] as $mood ) : ?>
<a href="<?php echo esc_url( $mood['url'] ?? '#' ); ?>" class="fedistream-tag fedistream-tag--mood"><?php echo esc_html( $mood['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="fedistream-single__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-playlist-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<?php esc_html_e( 'Play All', 'wp-fedistream' ); ?>
</button>
<button type="button" class="fedistream-btn fedistream-btn--secondary fedistream-btn--shuffle" data-playlist-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
<?php esc_html_e( 'Shuffle', 'wp-fedistream' ); ?>
</button>
</div>
</div>
</header>
<?php if ( ! empty( $post['content'] ) ) : ?>
<section class="fedistream-single__content">
<div class="fedistream-single__description">
<?php echo wp_kses_post( $post['content'] ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['tracks'] ) && is_array( $post['tracks'] ) ) : ?>
<section class="fedistream-single__tracklist">
<div class="fedistream-tracklist fedistream-tracklist--playlist">
<?php foreach ( $post['tracks'] as $index => $track ) : ?>
<div class="fedistream-tracklist__item" data-track-id="<?php echo esc_attr( $track['id'] ?? '' ); ?>">
<span class="fedistream-tracklist__number"><?php echo esc_html( $index + 1 ); ?></span>
<?php if ( ! empty( $track['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $track['thumbnail'] ); ?>" alt="" class="fedistream-tracklist__artwork">
<?php endif; ?>
<div class="fedistream-tracklist__info">
<a href="<?php echo esc_url( $track['permalink'] ?? '#' ); ?>" class="fedistream-tracklist__title"><?php echo esc_html( $track['title'] ?? '' ); ?></a>
<span class="fedistream-tracklist__artist">
<?php if ( ! empty( $track['artists'] ) && is_array( $track['artists'] ) ) : ?>
<?php
$artist_links = array();
foreach ( $track['artists'] as $artist ) {
$artist_links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $artist['url'] ?? '#' ),
esc_html( $artist['name'] ?? '' )
);
}
echo implode( ', ', $artist_links ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
<?php elseif ( ! empty( $track['artist'] ) ) : ?>
<?php echo esc_html( $track['artist'] ); ?>
<?php endif; ?>
</span>
</div>
<?php if ( ! empty( $track['explicit'] ) ) : ?>
<span class="fedistream-badge fedistream-badge--explicit">E</span>
<?php endif; ?>
<?php if ( ! empty( $track['duration_formatted'] ) ) : ?>
<span class="fedistream-tracklist__duration"><?php echo esc_html( $track['duration_formatted'] ); ?></span>
<?php endif; ?>
<button type="button" class="fedistream-tracklist__play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<?php endforeach; ?>
</div>
</section>
<?php else : ?>
<section class="fedistream-single__empty">
<div class="fedistream-empty">
<p><?php esc_html_e( 'This playlist is empty.', 'wp-fedistream' ); ?></p>
</div>
</section>
<?php endif; ?>
</article>

View File

@@ -1,107 +0,0 @@
{# Single playlist template #}
<article class="fedistream-single fedistream-single--playlist">
<header class="fedistream-single__header fedistream-single__header--playlist">
<div class="fedistream-single__artwork">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-single__image fedistream-single__image--playlist">
{% else %}
<div class="fedistream-single__placeholder fedistream-single__placeholder--playlist">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>
</div>
{% endif %}
{% if post.visibility == 'private' %}
<span class="fedistream-single__badge fedistream-single__badge--private">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>
{{ __('Private', 'wp-fedistream') }}
</span>
{% endif %}
</div>
<div class="fedistream-single__info">
<span class="fedistream-single__type-badge">{{ __('Playlist', 'wp-fedistream') }}</span>
<h1 class="fedistream-single__title">{{ post.title }}</h1>
{% if post.author %}
<p class="fedistream-single__author">
{{ __('Created by', 'wp-fedistream') }} <a href="{{ post.author_link }}">{{ post.author }}</a>
</p>
{% endif %}
<div class="fedistream-single__meta">
{% if post.track_count %}
<span class="fedistream-single__tracks">{{ post.track_count }} {{ post.track_count == 1 ? __('track', 'wp-fedistream') : __('tracks', 'wp-fedistream') }}</span>
{% endif %}
{% if post.duration_formatted %}
<span class="fedistream-single__duration">{{ post.duration_formatted }}</span>
{% endif %}
{% if post.updated_date %}
<span class="fedistream-single__updated">{{ __('Updated', 'wp-fedistream') }} {{ post.updated_date }}</span>
{% endif %}
</div>
{% if post.moods is not empty %}
<div class="fedistream-single__moods">
{% for mood in post.moods %}
<a href="{{ mood.link }}" class="fedistream-tag fedistream-tag--mood">{{ mood.name }}</a>
{% endfor %}
</div>
{% endif %}
<div class="fedistream-single__actions">
<button type="button" class="fedistream-btn fedistream-btn--primary fedistream-btn--play-all" data-playlist-id="{{ post.id }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
{{ __('Play All', 'wp-fedistream') }}
</button>
<button type="button" class="fedistream-btn fedistream-btn--secondary fedistream-btn--shuffle" data-playlist-id="{{ post.id }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
{{ __('Shuffle', 'wp-fedistream') }}
</button>
</div>
</div>
</header>
{% if post.content %}
<section class="fedistream-single__content">
<div class="fedistream-single__description">
{{ post.content|raw }}
</div>
</section>
{% endif %}
{% if post.tracks is not empty %}
<section class="fedistream-single__tracklist">
<div class="fedistream-tracklist fedistream-tracklist--playlist">
{% for track in post.tracks %}
<div class="fedistream-tracklist__item" data-track-id="{{ track.id }}">
<span class="fedistream-tracklist__number">{{ loop.index }}</span>
{% if track.thumbnail %}
<img src="{{ track.thumbnail }}" alt="" class="fedistream-tracklist__artwork">
{% endif %}
<div class="fedistream-tracklist__info">
<a href="{{ track.permalink }}" class="fedistream-tracklist__title">{{ track.title }}</a>
<span class="fedistream-tracklist__artist">
{% if track.artists is iterable %}
{% for artist in track.artists %}
<a href="{{ artist.link }}">{{ artist.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
{{ track.artist }}
{% endif %}
</span>
</div>
{% if track.explicit %}
<span class="fedistream-badge fedistream-badge--explicit">E</span>
{% endif %}
{% if track.duration_formatted %}
<span class="fedistream-tracklist__duration">{{ track.duration_formatted }}</span>
{% endif %}
<button type="button" class="fedistream-tracklist__play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
{% endfor %}
</div>
</section>
{% else %}
<section class="fedistream-single__empty">
<div class="fedistream-empty">
<p>{{ __('This playlist is empty.', 'wp-fedistream') }}</p>
</div>
</section>
{% endif %}
</article>

149
templates/single/track.php Normal file
View File

@@ -0,0 +1,149 @@
<?php
/**
* Single track template.
*
* @package WP_FediStream
*
* @var array $post Track data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<article class="fedistream-single fedistream-single--track">
<header class="fedistream-single__header fedistream-single__header--track">
<div class="fedistream-single__artwork">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-single__image fedistream-single__image--track">
<?php else : ?>
<div class="fedistream-single__placeholder fedistream-single__placeholder--track">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
<?php endif; ?>
<button type="button" class="fedistream-single__play-overlay" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="fedistream-single__info">
<h1 class="fedistream-single__title"><?php echo esc_html( $post['title'] ?? '' ); ?></h1>
<?php if ( ! empty( $post['artists'] ) && is_array( $post['artists'] ) ) : ?>
<p class="fedistream-single__artists">
<?php
$artist_links = array();
foreach ( $post['artists'] as $artist ) {
$artist_links[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $artist['url'] ?? '#' ),
esc_html( $artist['name'] ?? '' )
);
}
echo implode( ', ', $artist_links ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
?>
</p>
<?php endif; ?>
<?php if ( ! empty( $post['album_title'] ) ) : ?>
<p class="fedistream-single__album">
<?php esc_html_e( 'From', 'wp-fedistream' ); ?> <a href="<?php echo esc_url( $post['album_url'] ?? '#' ); ?>"><?php echo esc_html( $post['album_title'] ); ?></a>
</p>
<?php endif; ?>
<div class="fedistream-single__meta">
<?php if ( ! empty( $post['duration_formatted'] ) ) : ?>
<span class="fedistream-single__duration"><?php echo esc_html( $post['duration_formatted'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['play_count'] ) ) : ?>
<span class="fedistream-single__plays">
<?php
printf(
/* translators: %d: number of plays */
esc_html( _n( '%d play', '%d plays', $post['play_count'], 'wp-fedistream' ) ),
(int) $post['play_count']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $post['explicit'] ) ) : ?>
<span class="fedistream-badge fedistream-badge--explicit"><?php esc_html_e( 'Explicit', 'wp-fedistream' ); ?></span>
<?php endif; ?>
</div>
<?php if ( ! empty( $post['genres'] ) && is_array( $post['genres'] ) ) : ?>
<div class="fedistream-single__genres">
<?php foreach ( $post['genres'] as $genre ) : ?>
<a href="<?php echo esc_url( $genre['url'] ?? '#' ); ?>" class="fedistream-tag"><?php echo esc_html( $genre['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $post['moods'] ) && is_array( $post['moods'] ) ) : ?>
<div class="fedistream-single__moods">
<?php foreach ( $post['moods'] as $mood ) : ?>
<a href="<?php echo esc_url( $mood['url'] ?? '#' ); ?>" class="fedistream-tag fedistream-tag--mood"><?php echo esc_html( $mood['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</header>
<?php if ( ! empty( $post['audio_url'] ) ) : ?>
<section class="fedistream-single__player">
<div class="fedistream-player" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>" data-audio-url="<?php echo esc_url( $post['audio_url'] ); ?>">
<div class="fedistream-player__controls">
<button type="button" class="fedistream-player__btn fedistream-player__btn--play" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
</div>
<div class="fedistream-player__progress">
<span class="fedistream-player__time fedistream-player__time--current">0:00</span>
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="<?php esc_attr_e( 'Seek', 'wp-fedistream' ); ?>">
</div>
<span class="fedistream-player__time fedistream-player__time--total"><?php echo esc_html( $post['duration_formatted'] ?? '0:00' ); ?></span>
</div>
<div class="fedistream-player__volume">
<button type="button" class="fedistream-player__btn fedistream-player__btn--volume" aria-label="<?php esc_attr_e( 'Volume', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
<input type="range" class="fedistream-player__volume-slider" min="0" max="100" value="80" aria-label="<?php esc_attr_e( 'Volume', 'wp-fedistream' ); ?>">
</div>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['content'] ) ) : ?>
<section class="fedistream-single__content">
<h2 class="fedistream-section__title"><?php esc_html_e( 'About This Track', 'wp-fedistream' ); ?></h2>
<div class="fedistream-single__description">
<?php echo wp_kses_post( $post['content'] ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['lyrics'] ) ) : ?>
<section class="fedistream-single__lyrics">
<h2 class="fedistream-section__title"><?php esc_html_e( 'Lyrics', 'wp-fedistream' ); ?></h2>
<div class="fedistream-lyrics">
<?php echo nl2br( esc_html( $post['lyrics'] ) ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['credits'] ) ) : ?>
<section class="fedistream-single__credits">
<h2 class="fedistream-section__title"><?php esc_html_e( 'Credits', 'wp-fedistream' ); ?></h2>
<div class="fedistream-credits">
<?php echo wp_kses_post( $post['credits'] ); ?>
</div>
</section>
<?php endif; ?>
<?php if ( ! empty( $post['license'] ) ) : ?>
<section class="fedistream-single__license">
<p class="fedistream-license">
<strong><?php esc_html_e( 'License:', 'wp-fedistream' ); ?></strong>
<a href="<?php echo esc_url( $post['license']['link'] ?? '#' ); ?>"><?php echo esc_html( $post['license']['name'] ?? '' ); ?></a>
</p>
</section>
<?php endif; ?>
</article>

View File

@@ -1,120 +0,0 @@
{# Single track template #}
<article class="fedistream-single fedistream-single--track">
<header class="fedistream-single__header fedistream-single__header--track">
<div class="fedistream-single__artwork">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-single__image fedistream-single__image--track">
{% else %}
<div class="fedistream-single__placeholder fedistream-single__placeholder--track">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</div>
{% endif %}
<button type="button" class="fedistream-single__play-overlay" data-track-id="{{ post.id }}" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="fedistream-single__info">
<h1 class="fedistream-single__title">{{ post.title }}</h1>
{% if post.artists is not empty %}
<p class="fedistream-single__artists">
{% for artist in post.artists %}
<a href="{{ artist.link }}">{{ artist.name }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
{% if post.album %}
<p class="fedistream-single__album">
{{ __('From', 'wp-fedistream') }} <a href="{{ post.album_link }}">{{ post.album }}</a>
</p>
{% endif %}
<div class="fedistream-single__meta">
{% if post.duration_formatted %}
<span class="fedistream-single__duration">{{ post.duration_formatted }}</span>
{% endif %}
{% if post.play_count %}
<span class="fedistream-single__plays">{{ post.play_count }} {{ post.play_count == 1 ? __('play', 'wp-fedistream') : __('plays', 'wp-fedistream') }}</span>
{% endif %}
{% if post.explicit %}
<span class="fedistream-badge fedistream-badge--explicit">{{ __('Explicit', 'wp-fedistream') }}</span>
{% endif %}
</div>
{% if post.genres is not empty %}
<div class="fedistream-single__genres">
{% for genre in post.genres %}
<a href="{{ genre.link }}" class="fedistream-tag">{{ genre.name }}</a>
{% endfor %}
</div>
{% endif %}
{% if post.moods is not empty %}
<div class="fedistream-single__moods">
{% for mood in post.moods %}
<a href="{{ mood.link }}" class="fedistream-tag fedistream-tag--mood">{{ mood.name }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</header>
{% if post.audio_url %}
<section class="fedistream-single__player">
<div class="fedistream-player" data-track-id="{{ post.id }}" data-audio-url="{{ post.audio_url }}">
<div class="fedistream-player__controls">
<button type="button" class="fedistream-player__btn fedistream-player__btn--play" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg class="fedistream-player__icon fedistream-player__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-player__icon fedistream-player__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
</div>
<div class="fedistream-player__progress">
<span class="fedistream-player__time fedistream-player__time--current">0:00</span>
<div class="fedistream-player__bar">
<div class="fedistream-player__bar-progress"></div>
<input type="range" class="fedistream-player__seek" min="0" max="100" value="0" aria-label="{{ __('Seek', 'wp-fedistream') }}">
</div>
<span class="fedistream-player__time fedistream-player__time--total">{{ post.duration_formatted|default('0:00') }}</span>
</div>
<div class="fedistream-player__volume">
<button type="button" class="fedistream-player__btn fedistream-player__btn--volume" aria-label="{{ __('Volume', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
</button>
<input type="range" class="fedistream-player__volume-slider" min="0" max="100" value="80" aria-label="{{ __('Volume', 'wp-fedistream') }}">
</div>
</div>
</section>
{% endif %}
{% if post.content %}
<section class="fedistream-single__content">
<h2 class="fedistream-section__title">{{ __('About This Track', 'wp-fedistream') }}</h2>
<div class="fedistream-single__description">
{{ post.content|raw }}
</div>
</section>
{% endif %}
{% if post.lyrics %}
<section class="fedistream-single__lyrics">
<h2 class="fedistream-section__title">{{ __('Lyrics', 'wp-fedistream') }}</h2>
<div class="fedistream-lyrics">
{{ post.lyrics|nl2br }}
</div>
</section>
{% endif %}
{% if post.credits %}
<section class="fedistream-single__credits">
<h2 class="fedistream-section__title">{{ __('Credits', 'wp-fedistream') }}</h2>
<div class="fedistream-credits">
{{ post.credits|raw }}
</div>
</section>
{% endif %}
{% if post.license %}
<section class="fedistream-single__license">
<p class="fedistream-license">
<strong>{{ __('License:', 'wp-fedistream') }}</strong>
<a href="{{ post.license.link }}">{{ post.license.name }}</a>
</p>
</section>
{% endif %}
</article>

View File

@@ -0,0 +1,70 @@
<?php
/**
* Featured Artist Widget Template.
*
* @package WP_FediStream
*
* @var array $post Artist data array.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<?php if ( ! empty( $post ) ) : ?>
<div class="fedistream-widget__featured">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>" class="fedistream-widget__featured-link">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-widget__featured-image">
<?php else : ?>
<span class="fedistream-widget__placeholder fedistream-widget__placeholder--large">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</span>
<?php endif; ?>
</a>
<div class="fedistream-widget__featured-info">
<h4 class="fedistream-widget__featured-name">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>"><?php echo esc_html( $post['title'] ?? '' ); ?></a>
</h4>
<?php if ( ! empty( $post['artist_type_label'] ) ) : ?>
<span class="fedistream-widget__featured-type"><?php echo esc_html( $post['artist_type_label'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['genres'] ) && is_array( $post['genres'] ) ) : ?>
<div class="fedistream-widget__featured-genres">
<?php foreach ( array_slice( $post['genres'], 0, 3 ) as $genre ) : ?>
<a href="<?php echo esc_url( $genre['url'] ?? '#' ); ?>" class="fedistream-tag fedistream-tag--small"><?php echo esc_html( $genre['name'] ?? '' ); ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $post['album_count'] ) || ! empty( $post['track_count'] ) ) : ?>
<div class="fedistream-widget__featured-stats">
<?php if ( ! empty( $post['album_count'] ) ) : ?>
<span>
<?php
printf(
/* translators: %d: number of albums */
esc_html( _n( '%d album', '%d albums', $post['album_count'], 'wp-fedistream' ) ),
(int) $post['album_count']
);
?>
</span>
<?php endif; ?>
<?php if ( ! empty( $post['track_count'] ) ) : ?>
<span>
<?php
printf(
/* translators: %d: number of tracks */
esc_html( _n( '%d track', '%d tracks', $post['track_count'], 'wp-fedistream' ) ),
(int) $post['track_count']
);
?>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php else : ?>
<p class="fedistream-widget__empty"><?php esc_html_e( 'No artist selected.', 'wp-fedistream' ); ?></p>
<?php endif; ?>

View File

@@ -1,41 +0,0 @@
{# Featured Artist Widget Template #}
{% if post %}
<div class="fedistream-widget__featured">
<a href="{{ post.permalink }}" class="fedistream-widget__featured-link">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-widget__featured-image">
{% else %}
<span class="fedistream-widget__placeholder fedistream-widget__placeholder--large">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</span>
{% endif %}
</a>
<div class="fedistream-widget__featured-info">
<h4 class="fedistream-widget__featured-name">
<a href="{{ post.permalink }}">{{ post.title }}</a>
</h4>
{% if post.artist_type %}
<span class="fedistream-widget__featured-type">{{ post.artist_type }}</span>
{% endif %}
{% if post.genres is not empty %}
<div class="fedistream-widget__featured-genres">
{% for genre in post.genres|slice(0, 3) %}
<a href="{{ genre.link }}" class="fedistream-tag fedistream-tag--small">{{ genre.name }}</a>
{% endfor %}
</div>
{% endif %}
{% if post.album_count or post.track_count %}
<div class="fedistream-widget__featured-stats">
{% if post.album_count %}
<span>{{ post.album_count }} {{ post.album_count == 1 ? __('album', 'wp-fedistream') : __('albums', 'wp-fedistream') }}</span>
{% endif %}
{% if post.track_count %}
<span>{{ post.track_count }} {{ post.track_count == 1 ? __('track', 'wp-fedistream') : __('tracks', 'wp-fedistream') }}</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% else %}
<p class="fedistream-widget__empty">{{ __('No artist selected.', 'wp-fedistream') }}</p>
{% endif %}

View File

@@ -1,10 +1,25 @@
{# Now Playing Widget Template #} <?php
/**
* Now Playing Widget Template.
*
* @package WP_FediStream
*
* @var bool $show_player Whether to show player controls.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$show_player = $show_player ?? true;
?>
<div class="fedistream-now-playing" data-widget="now-playing"> <div class="fedistream-now-playing" data-widget="now-playing">
<div class="fedistream-now-playing__idle"> <div class="fedistream-now-playing__idle">
<span class="fedistream-now-playing__placeholder"> <span class="fedistream-now-playing__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</span> </span>
<span class="fedistream-now-playing__message">{{ __('Nothing playing', 'wp-fedistream') }}</span> <span class="fedistream-now-playing__message"><?php esc_html_e( 'Nothing playing', 'wp-fedistream' ); ?></span>
</div> </div>
<div class="fedistream-now-playing__active" style="display: none;"> <div class="fedistream-now-playing__active" style="display: none;">
<div class="fedistream-now-playing__track"> <div class="fedistream-now-playing__track">
@@ -14,16 +29,16 @@
<span class="fedistream-now-playing__artist"></span> <span class="fedistream-now-playing__artist"></span>
</div> </div>
</div> </div>
{% if show_player %} <?php if ( $show_player ) : ?>
<div class="fedistream-now-playing__controls"> <div class="fedistream-now-playing__controls">
<button type="button" class="fedistream-now-playing__btn fedistream-now-playing__btn--prev" aria-label="{{ __('Previous', 'wp-fedistream') }}"> <button type="button" class="fedistream-now-playing__btn fedistream-now-playing__btn--prev" aria-label="<?php esc_attr_e( 'Previous', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button> </button>
<button type="button" class="fedistream-now-playing__btn fedistream-now-playing__btn--play" aria-label="{{ __('Play/Pause', 'wp-fedistream') }}"> <button type="button" class="fedistream-now-playing__btn fedistream-now-playing__btn--play" aria-label="<?php esc_attr_e( 'Play/Pause', 'wp-fedistream' ); ?>">
<svg class="fedistream-now-playing__icon fedistream-now-playing__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> <svg class="fedistream-now-playing__icon fedistream-now-playing__icon--play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="fedistream-now-playing__icon fedistream-now-playing__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> <svg class="fedistream-now-playing__icon fedistream-now-playing__icon--pause" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button> </button>
<button type="button" class="fedistream-now-playing__btn fedistream-now-playing__btn--next" aria-label="{{ __('Next', 'wp-fedistream') }}"> <button type="button" class="fedistream-now-playing__btn fedistream-now-playing__btn--next" aria-label="<?php esc_attr_e( 'Next', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
</div> </div>
@@ -36,6 +51,6 @@
<span class="fedistream-now-playing__time fedistream-now-playing__time--total">0:00</span> <span class="fedistream-now-playing__time fedistream-now-playing__time--total">0:00</span>
</div> </div>
</div> </div>
{% endif %} <?php endif; ?>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,45 @@
<?php
/**
* Popular Tracks Widget Template.
*
* @package WP_FediStream
*
* @var array $posts Array of track data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<ol class="fedistream-widget__list fedistream-widget__list--tracks">
<?php foreach ( $posts as $post ) : ?>
<li class="fedistream-widget__item" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>" class="fedistream-widget__link">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-widget__image">
<?php else : ?>
<span class="fedistream-widget__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</span>
<?php endif; ?>
<span class="fedistream-widget__info">
<span class="fedistream-widget__title"><?php echo esc_html( $post['title'] ?? '' ); ?></span>
<?php if ( ! empty( $post['artist'] ) ) : ?>
<span class="fedistream-widget__artist"><?php echo esc_html( $post['artist'] ); ?></span>
<?php endif; ?>
</span>
<?php if ( ! empty( $post['play_count'] ) ) : ?>
<span class="fedistream-widget__plays"><?php echo esc_html( number_format( $post['play_count'] ) ); ?></span>
<?php endif; ?>
</a>
<button type="button" class="fedistream-widget__play" data-track-id="<?php echo esc_attr( $post['id'] ?? '' ); ?>" aria-label="<?php esc_attr_e( 'Play', 'wp-fedistream' ); ?>">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</li>
<?php endforeach; ?>
</ol>
<?php else : ?>
<p class="fedistream-widget__empty"><?php esc_html_e( 'No tracks yet.', 'wp-fedistream' ); ?></p>
<?php endif; ?>

View File

@@ -1,32 +0,0 @@
{# Popular Tracks Widget Template #}
{% if posts is not empty %}
<ol class="fedistream-widget__list fedistream-widget__list--tracks">
{% for post in posts %}
<li class="fedistream-widget__item" data-track-id="{{ post.id }}">
<a href="{{ post.permalink }}" class="fedistream-widget__link">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-widget__image">
{% else %}
<span class="fedistream-widget__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
</span>
{% endif %}
<span class="fedistream-widget__info">
<span class="fedistream-widget__title">{{ post.title }}</span>
{% if post.artist %}
<span class="fedistream-widget__artist">{{ post.artist }}</span>
{% endif %}
</span>
{% if post.play_count %}
<span class="fedistream-widget__plays">{{ post.play_count|number_format }}</span>
{% endif %}
</a>
<button type="button" class="fedistream-widget__play" data-track-id="{{ post.id }}" aria-label="{{ __('Play', 'wp-fedistream') }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
</li>
{% endfor %}
</ol>
{% else %}
<p class="fedistream-widget__empty">{{ __('No tracks yet.', 'wp-fedistream') }}</p>
{% endif %}

View File

@@ -0,0 +1,42 @@
<?php
/**
* Recent Releases Widget Template.
*
* @package WP_FediStream
*
* @var array $posts Array of album data.
*/
// Prevent direct file access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<?php if ( ! empty( $posts ) && is_array( $posts ) ) : ?>
<ul class="fedistream-widget__list fedistream-widget__list--releases">
<?php foreach ( $posts as $post ) : ?>
<li class="fedistream-widget__item">
<a href="<?php echo esc_url( $post['permalink'] ?? '#' ); ?>" class="fedistream-widget__link">
<?php if ( ! empty( $post['thumbnail'] ) ) : ?>
<img src="<?php echo esc_url( $post['thumbnail'] ); ?>" alt="<?php echo esc_attr( $post['title'] ?? '' ); ?>" class="fedistream-widget__image">
<?php else : ?>
<span class="fedistream-widget__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</span>
<?php endif; ?>
<span class="fedistream-widget__info">
<span class="fedistream-widget__title"><?php echo esc_html( $post['title'] ?? '' ); ?></span>
<?php if ( ! empty( $post['artist_name'] ) ) : ?>
<span class="fedistream-widget__artist"><?php echo esc_html( $post['artist_name'] ); ?></span>
<?php endif; ?>
<?php if ( ! empty( $post['release_date'] ) ) : ?>
<span class="fedistream-widget__date"><?php echo esc_html( $post['release_date'] ); ?></span>
<?php endif; ?>
</span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php else : ?>
<p class="fedistream-widget__empty"><?php esc_html_e( 'No releases yet.', 'wp-fedistream' ); ?></p>
<?php endif; ?>

View File

@@ -1,29 +0,0 @@
{# Recent Releases Widget Template #}
{% if posts is not empty %}
<ul class="fedistream-widget__list fedistream-widget__list--releases">
{% for post in posts %}
<li class="fedistream-widget__item">
<a href="{{ post.permalink }}" class="fedistream-widget__link">
{% if post.thumbnail %}
<img src="{{ post.thumbnail }}" alt="{{ post.title|e('html_attr') }}" class="fedistream-widget__image">
{% else %}
<span class="fedistream-widget__placeholder">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 14.5c-2.49 0-4.5-2.01-4.5-4.5S9.51 7.5 12 7.5s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-5.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1z"/></svg>
</span>
{% endif %}
<span class="fedistream-widget__info">
<span class="fedistream-widget__title">{{ post.title }}</span>
{% if post.artist %}
<span class="fedistream-widget__artist">{{ post.artist }}</span>
{% endif %}
{% if post.release_date %}
<span class="fedistream-widget__date">{{ post.release_date }}</span>
{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="fedistream-widget__empty">{{ __('No releases yet.', 'wp-fedistream') }}</p>
{% endif %}

View File

@@ -3,7 +3,7 @@
* Plugin Name: WP FediStream * Plugin Name: WP FediStream
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-fedistream * Plugin URI: https://src.bundespruefstelle.ch/magdev/wp-fedistream
* Description: Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels. * Description: Stream music over ActivityPub - Build your own music streaming platform for Musicians and Labels.
* Version: 0.2.0 * Version: 0.6.0
* Requires at least: 6.4 * Requires at least: 6.4
* Requires PHP: 8.3 * Requires PHP: 8.3
* Author: Marco Graetsch * Author: Marco Graetsch
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
* *
* @var string * @var string
*/ */
define( 'WP_FEDISTREAM_VERSION', '0.2.0' ); define( 'WP_FEDISTREAM_VERSION', '0.6.0' );
/** /**
* Plugin file path. * Plugin file path.