This commit is contained in:
Nick 2025-06-20 19:59:10 +01:00
parent 1f429c7e83
commit 7d23a8e337
4201 changed files with 6421 additions and 272 deletions

View File

@ -43,9 +43,96 @@
<Content Update="web.config"> <Content Update="web.config">
<CopyToPublishDirectory>Never</CopyToPublishDirectory> <CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content> </Content>
<Content Update="wwwroot\android-icon-144x144.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\android-icon-192x192.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\android-icon-36x36.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\android-icon-48x48.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\android-icon-72x72.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\android-icon-96x96.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-114x114.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-120x120.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-144x144.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-152x152.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-180x180.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-57x57.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-60x60.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-72x72.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-76x76.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon-precomposed.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\apple-icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\browserconfig.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\favicon-16x16.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\favicon-32x32.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\favicon-96x96.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\favicon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\js\plyr.js"> <Content Update="wwwroot\js\plyr.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Update="wwwroot\manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\ms-icon-144x144.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\ms-icon-150x150.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\ms-icon-310x310.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\ms-icon-70x70.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\robots.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\service-worker.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -58,6 +145,7 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" /> <PackageReference Include="Microsoft.Web.Administration" Version="11.1.0" />
<PackageReference Include="NAudio" Version="2.2.1" />
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.Fonts" Version="2.1.3" /> <PackageReference Include="SixLabors.Fonts" Version="2.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />

View File

@ -0,0 +1,123 @@
using CatherineLynwood.Services;
using Microsoft.AspNetCore.Mvc;
namespace CatherineLynwood.Controllers
{
[ApiController]
[Route("api/audio")]
public class AudioController : ControllerBase
{
private readonly IWebHostEnvironment _env;
private readonly ChapterAudioMapCache _cache;
private readonly AudioTokenService _tokenService;
public AudioController(IWebHostEnvironment env, ChapterAudioMapCache cache, AudioTokenService tokenService)
{
_env = env;
_cache = cache;
_tokenService = tokenService;
}
[HttpGet("secure-stream/{id}")]
public IActionResult SecureStream(string id, [FromQuery] long expires, [FromQuery] string token)
{
if (!_tokenService.IsValid(id, expires, token))
return Unauthorized("Invalid or expired token.");
var segment = _cache.Get(id);
if (segment == null)
return NotFound($"No audio segment found for ID: {id}");
var filePath = Path.Combine(_env.WebRootPath, "chapters", segment.ChapterFolder, segment.File);
if (!System.IO.File.Exists(filePath))
return NotFound($"File not found: {filePath}");
var stream = System.IO.File.OpenRead(filePath);
return File(stream, "audio/mpeg");
}
[HttpGet("stream/{id}")]
public IActionResult Stream(string id)
{
var segment = _cache.Get(id);
if (segment == null)
return NotFound($"No audio segment found for ID: {id}");
var filePath = Path.Combine(_env.WebRootPath, "chapters", segment.ChapterFolder, segment.File);
if (!System.IO.File.Exists(filePath))
return NotFound($"File not found: {filePath}");
var stream = System.IO.File.OpenRead(filePath);
return File(stream, "audio/mpeg");
}
[HttpGet("playlist/chapter/{chapterNumber:int}")]
public IActionResult GetChapterPlaylist(int chapterNumber)
{
var segments = _cache.GetAll()
.Where(s => s.ChapterFolder.EndsWith($"Chapter_{chapterNumber}", StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.File)
.ToList();
if (!segments.Any())
return NotFound();
var playlist = new List<object>();
var currentTime = TimeSpan.Zero;
foreach (var segment in segments)
{
playlist.Add(new
{
id = segment.Id,
display = currentTime.ToString(@"mm\:ss")
});
currentTime += segment.Duration;
}
return Ok(playlist);
}
[HttpGet("playlist/all")]
public IActionResult GetAllChapters()
{
var segments = _cache.GetAll()
.OrderBy(s => s.ChapterFolder)
.ThenBy(s => s.File)
.ToList();
var grouped = segments
.GroupBy(s => s.ChapterFolder)
.Select(group => new
{
chapter = group.Key,
segments = group.Select(s => new
{
id = s.Id,
display = s.Duration.ToString(@"mm\:ss"),
text = s.Text ?? ""
}).ToList()
});
return Ok(grouped);
}
[HttpGet("token-url/{id}")]
public IActionResult GetTokenUrl(string id)
{
var segment = _cache.Get(id);
if (segment == null)
return NotFound();
var url = _tokenService.GenerateUrl(id);
return Content(url);
}
}
}

View File

@ -2,30 +2,70 @@
namespace CatherineLynwood.Controllers namespace CatherineLynwood.Controllers
{ {
[Route("the-alpha-flame/extras/discovery")] [Route("the-alpha-flame/discovery")]
public class DiscoveryController : Controller public class DiscoveryController : Controller
{ {
public IActionResult Index() #region Public Methods
[Route("chapters/chapter-1-beth")]
public IActionResult Chapter1()
{ {
return View(); return View();
} }
[Route("epilogue")] [Route("chapters/chapter-13-susie")]
public IActionResult Chapter13()
{
return View();
}
[Route("chapters/chapter-2-maggie")]
public IActionResult Chapter2()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras/epilogue")]
public IActionResult Epilogue() public IActionResult Epilogue()
{ {
return View(); return View();
} }
[Route("scrap-book")] [BookAccess(1, 1)]
[Route("extras")]
public IActionResult Extras()
{
return View();
}
[Route("")]
public IActionResult Index()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras/listen")]
public IActionResult Listen()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras/maggies-designs")]
public IActionResult MaggiesDesigns()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras/scrap-book")]
public IActionResult ScrapBook() public IActionResult ScrapBook()
{ {
return View(); return View();
} }
[Route("maggies-designs")] #endregion Public Methods
public IActionResult MaggiesDesigns()
{
return View();
}
} }
} }

View File

@ -40,12 +40,12 @@ namespace CatherineLynwood.Controllers
new SitemapEntry { Url = Url.Action("Index", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Index", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Blog", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Blog", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Characters", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Characters", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Discovery", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Index", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Index", "Publishing", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Index", "Publishing", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Privacy", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Privacy", "Home", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Chapter1", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Chapter1", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Chapter2", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Chapter2", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
new SitemapEntry { Url = Url.Action("Chapter13", "TheAlphaFlame", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow }, new SitemapEntry { Url = Url.Action("Chapter13", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
// Additional static pages // Additional static pages
}; };

View File

@ -36,6 +36,7 @@ namespace CatherineLynwood.Controllers
return View(); return View();
} }
[Route("blog")] [Route("blog")]
public async Task<IActionResult> Blog(BlogFilter blogFilter) public async Task<IActionResult> Blog(BlogFilter blogFilter)
{ {
@ -84,6 +85,12 @@ namespace CatherineLynwood.Controllers
public async Task<IActionResult> BlogItem(string slug, bool showThanks) public async Task<IActionResult> BlogItem(string slug, bool showThanks)
{ {
Blog blog = await _dataAccess.GetBlogItemAsync(slug); Blog blog = await _dataAccess.GetBlogItemAsync(slug);
if (blog.Title == null)
{
return RedirectPermanent("/the-alpha-flame/blog");
}
blog.ShowThanks = showThanks; blog.ShowThanks = showThanks;
if (blog.Template == "slideshow") if (blog.Template == "slideshow")
@ -94,22 +101,7 @@ namespace CatherineLynwood.Controllers
return View("DefaultTemplate", blog); return View("DefaultTemplate", blog);
} }
[Route("chapters/chapter-1-beth")]
public IActionResult Chapter1()
{
return View();
}
[Route("chapters/chapter-2-maggie")]
public IActionResult Chapter2()
{
return View();
}
[Route("chapters/chapter-13-susie")]
public IActionResult Chapter13()
{
return View();
}
[Route("characters")] [Route("characters")]
public IActionResult Characters() public IActionResult Characters()
@ -166,19 +158,7 @@ namespace CatherineLynwood.Controllers
return RedirectToAction("BlogItem", new { slug = blogUrl, showThanks = showThanks }); return RedirectToAction("BlogItem", new { slug = blogUrl, showThanks = showThanks });
} }
[Route("discovery")] [Route("")]
public IActionResult Discovery()
{
return View();
}
[BookAccess(1, 1)]
[Route("extras")]
public IActionResult Extras()
{
return View();
}
public IActionResult Index() public IActionResult Index()
{ {
return View(); return View();

View File

@ -0,0 +1,39 @@
namespace CatherineLynwood.Middleware
{
public class BotFilterMiddleware
{
#region Private Fields
private static readonly List<string> BadBots = new()
{
"AhrefsBot", "SemrushBot", "MJ12bot", "DotBot", "Baiduspider", "YandexBot"
};
private readonly RequestDelegate _next;
#endregion Private Fields
#region Public Constructors
public BotFilterMiddleware(RequestDelegate next) => _next = next;
#endregion Public Constructors
#region Public Methods
public async Task Invoke(HttpContext context)
{
var userAgent = context.Request.Headers["User-Agent"].ToString();
if (BadBots.Any(bot => userAgent.Contains(bot, StringComparison.OrdinalIgnoreCase)))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Forbidden");
return;
}
await _next(context);
}
#endregion Public Methods
}
}

View File

@ -3,21 +3,29 @@
public class RedirectToWwwMiddleware public class RedirectToWwwMiddleware
{ {
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private IWebHostEnvironment _environment;
public RedirectToWwwMiddleware(RequestDelegate next) public RedirectToWwwMiddleware(RequestDelegate next, IWebHostEnvironment environment)
{ {
_next = next; _next = next;
_environment = environment;
} }
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
var host = context.Request.Host.Host; var host = context.Request.Host.Host;
if (host.Equals("catherinelynwood.com", System.StringComparison.OrdinalIgnoreCase)) var schema = context.Request.Scheme;
if (_environment.IsProduction())
{
if (host.Equals("catherinelynwood.com", StringComparison.OrdinalIgnoreCase) || schema.Equals("http", StringComparison.OrdinalIgnoreCase))
{ {
var newUrl = $"https://www.catherinelynwood.com{context.Request.Path}{context.Request.QueryString}"; var newUrl = $"https://www.catherinelynwood.com{context.Request.Path}{context.Request.QueryString}";
context.Response.Redirect(newUrl, permanent: true); context.Response.Redirect(newUrl, permanent: true);
return; // End the middleware pipeline. return; // End the middleware pipeline.
} }
}
// Continue to the next middleware. // Continue to the next middleware.
await _next(context); await _next(context);

View File

@ -0,0 +1,23 @@
namespace CatherineLynwood.Models
{
public class ChapterAudioSegment
{
#region Public Properties
public string ChapterFolder { get; set; } = default!;
public string Display { get; set; } = default!;
public TimeSpan Duration { get; set; }
public string File { get; set; } = default!;
public string Id { get; set; } = default!;
public string? Text { get; set; }
public string Url { get; set; } = default!;
#endregion Public Properties
}
}

View File

@ -0,0 +1,13 @@
namespace CatherineLynwood.Models
{
public class ChapterPlayerViewModel
{
#region Public Properties
public int ChapterNumber { get; set; }
public string ChapterTitle { get; set; }
#endregion Public Properties
}
}

View File

@ -38,6 +38,12 @@ namespace CatherineLynwood
// ✅ Register the book access code service // ✅ Register the book access code service
builder.Services.AddScoped<IAccessCodeService, AccessCodeService>(); builder.Services.AddScoped<IAccessCodeService, AccessCodeService>();
builder.Services.AddSingleton<ChapterAudioMapCache>();
builder.Services.AddHostedService<ChapterAudioMapService>();
builder.Services.AddSingleton<AudioTokenService>();
// Add response compression services // Add response compression services
builder.Services.AddResponseCompression(options => builder.Services.AddResponseCompression(options =>
{ {
@ -77,6 +83,7 @@ namespace CatherineLynwood
} }
app.UseMiddleware<BlockPhpRequestsMiddleware>(); app.UseMiddleware<BlockPhpRequestsMiddleware>();
app.UseMiddleware<BotFilterMiddleware>();
app.UseMiddleware<RedirectToWwwMiddleware>(); app.UseMiddleware<RedirectToWwwMiddleware>();
app.UseMiddleware<RefererValidationMiddleware>(); app.UseMiddleware<RefererValidationMiddleware>();
app.UseMiddleware<HoneypotLoggingMiddleware>(); app.UseMiddleware<HoneypotLoggingMiddleware>();

View File

@ -0,0 +1,41 @@
using System.Security.Cryptography;
using System.Text;
namespace CatherineLynwood.Services
{
public class AudioTokenService
{
private readonly string _secret;
private readonly int _expirySeconds;
public AudioTokenService(IConfiguration config)
{
_secret = config["AudioSecurity:HmacSecretKey"];
_expirySeconds = int.Parse(config["AudioSecurity:TokenExpirySeconds"]);
}
public string GenerateToken(string id, long expires)
{
var payload = $"{id}:{expires}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToBase64String(hash);
}
public bool IsValid(string id, long expires, string token)
{
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires)
return false;
var expected = GenerateToken(id, expires);
return expected == token;
}
public string GenerateUrl(string id)
{
var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _expirySeconds;
var token = GenerateToken(id, expires);
return $"/api/audio/stream/{id}?expires={expires}&token={Uri.EscapeDataString(token)}";
}
}
}

View File

@ -0,0 +1,29 @@
using CatherineLynwood.Models;
namespace CatherineLynwood.Services
{
public class ChapterAudioMapCache
{
#region Private Fields
private readonly Dictionary<string, ChapterAudioSegment> _idMap = new();
#endregion Private Fields
#region Public Methods
public void Add(ChapterAudioSegment segment)
{
_idMap[segment.Id] = segment;
}
public ChapterAudioSegment? Get(string id)
{
return _idMap.TryGetValue(id, out var segment) ? segment : null;
}
public IReadOnlyCollection<ChapterAudioSegment> GetAll() => _idMap.Values;
#endregion Public Methods
}
}

View File

@ -0,0 +1,130 @@
using NAudio.Wave;
using CatherineLynwood.Models;
using System.Text.RegularExpressions;
namespace CatherineLynwood.Services
{
public class ChapterAudioMapService : IHostedService
{
#region Private Fields
private readonly AudioTokenService _audioTokenService;
private readonly ChapterAudioMapCache _cache;
private readonly IWebHostEnvironment _env;
private readonly ILogger<ChapterAudioMapService> _logger;
#endregion Private Fields
#region Public Constructors
public ChapterAudioMapService(
IWebHostEnvironment env,
ILogger<ChapterAudioMapService> logger,
ChapterAudioMapCache cache,
AudioTokenService audioTokenService)
{
_env = env;
_logger = logger;
_cache = cache;
_audioTokenService = audioTokenService;
}
#endregion Public Constructors
#region Public Methods
public Task StartAsync(CancellationToken cancellationToken)
{
var chaptersPath = Path.Combine(_env.WebRootPath, "chapters");
if (!Directory.Exists(chaptersPath))
{
_logger.LogWarning("Chapters directory not found at {Path}", chaptersPath);
return Task.CompletedTask;
}
foreach (var chapterDir in Directory.GetDirectories(chaptersPath))
{
var folderName = Path.GetFileName(chapterDir);
var match = Regex.Match(folderName, @"Chapter_(\d+)", RegexOptions.IgnoreCase);
if (!match.Success)
{
_logger.LogWarning("Skipping folder {Folder} — no chapter number found", folderName);
continue;
}
var textFile = Directory.GetFiles(chapterDir, "Chapter*.txt").FirstOrDefault();
List<string> textLines = [];
if (textFile != null)
{
textLines = File.ReadAllLines(textFile)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
}
else
{
_logger.LogWarning("No text file found in {Folder}", chapterDir);
}
var files = Directory.GetFiles(chapterDir, "*.mp3")
.OrderBy(f => f)
.ToList();
for (int i = 0; i < files.Count; i++)
{
var id = Guid.NewGuid().ToString("N");
var filename = Path.GetFileName(files[i]);
var duration = TimeSpan.Zero;
try
{
using var reader = new Mp3FileReader(files[i]);
duration = reader.TotalTime;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read duration for {File}", filename);
}
var text = i < textLines.Count ? textLines[i] : null;
var segment = new ChapterAudioSegment
{
Id = id,
File = filename,
ChapterFolder = folderName,
Duration = duration,
Text = text
};
_cache.Add(segment);
}
_logger.LogInformation("Cached {Count} segments for {Folder}", files.Count, folderName);
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
#endregion Public Methods
#region Private Methods
private string FormatDuration(TimeSpan duration)
{
return duration.TotalHours >= 1
? duration.ToString(@"hh\:mm\:ss")
: duration.ToString(@"mm\:ss");
}
#endregion Private Methods
}
}

View File

@ -1,36 +1,56 @@
using System.Text.Json; using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace CatherineLynwood.TagHelpers namespace CatherineLynwood.TagHelpers
{ {
[HtmlTargetElement("MetaTag")] [HtmlTargetElement("MetaTag")]
public class MetaTagHelper : TagHelper public class MetaTagHelper : TagHelper
{ {
// Shared properties #region Public Properties
public string MetaTitle { get; set; }
public string MetaDescription { get; set; }
public string MetaKeywords { get; set; }
public string MetaAuthor { get; set; }
public string MetaUrl { get; set; }
public string MetaImage { get; set; }
public string MetaImageAlt { get; set; }
// Open Graph specific properties public DateTime? ArticleModifiedTime { get; set; }
public string OgSiteName { get; set; }
public string OgType { get; set; } = "article";
// Article specific properties // Article specific properties
public DateTime? ArticlePublishedTime { get; set; } public DateTime? ArticlePublishedTime { get; set; }
public DateTime? ArticleModifiedTime { get; set; }
public string MetaAuthor { get; set; }
public string MetaDescription { get; set; }
public string MetaImage { get; set; }
public string MetaImageAlt { get; set; }
public string MetaKeywords { get; set; }
// Shared properties
public string MetaTitle { get; set; }
public string MetaUrl { get; set; }
// Open Graph specific properties
public string OgSiteName { get; set; }
public string OgType { get; set; } = "article";
// Twitter specific properties // Twitter specific properties
public string TwitterCardType { get; set; } = "summary_large_image"; public string TwitterCardType { get; set; } = "summary_large_image";
public string TwitterSiteHandle { get; set; }
public string TwitterCreatorHandle { get; set; } public string TwitterCreatorHandle { get; set; }
public int? TwitterPlayerWidth { get; set; }
public int? TwitterPlayerHeight { get; set; } public int? TwitterPlayerHeight { get; set; }
public int? TwitterPlayerWidth { get; set; }
public string TwitterSiteHandle { get; set; }
public string TwitterVideoUrl { get; set; }
#endregion Public Properties
// Optional: a full URL to a video (e.g., MP4 or embedded player)
#region Public Methods
public override void Process(TagHelperContext context, TagHelperOutput output) public override void Process(TagHelperContext context, TagHelperOutput output)
{ {
output.TagName = null; // Suppress output tag output.TagName = null; // Suppress output tag
@ -79,55 +99,29 @@ namespace CatherineLynwood.TagHelpers
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">"); metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
if (!string.IsNullOrWhiteSpace(MetaImageAlt)) if (!string.IsNullOrWhiteSpace(MetaImageAlt))
metaTags.AppendLine($"<meta name=\"twitter:image:alt\" content=\"{MetaImageAlt}\">"); metaTags.AppendLine($"<meta name=\"twitter:image:alt\" content=\"{MetaImageAlt}\">");
if (!string.IsNullOrWhiteSpace(MetaUrl)) if (!string.IsNullOrWhiteSpace(TwitterVideoUrl))
{ {
metaTags.AppendLine($"<meta name=\"twitter:player\" content=\"{MetaUrl}\">"); metaTags.AppendLine("<meta name=\"twitter:card\" content=\"player\">");
metaTags.AppendLine($"<meta name=\"twitter:player\" content=\"{TwitterVideoUrl}\">");
if (TwitterPlayerWidth.HasValue) if (TwitterPlayerWidth.HasValue)
metaTags.AppendLine($"<meta name=\"twitter:player:width\" content=\"{TwitterPlayerWidth}\">"); metaTags.AppendLine($"<meta name=\"twitter:player:width\" content=\"{TwitterPlayerWidth}\">");
if (TwitterPlayerHeight.HasValue) if (TwitterPlayerHeight.HasValue)
metaTags.AppendLine($"<meta name=\"twitter:player:height\" content=\"{TwitterPlayerHeight}\">"); metaTags.AppendLine($"<meta name=\"twitter:player:height\" content=\"{TwitterPlayerHeight}\">");
if (!string.IsNullOrWhiteSpace(MetaImage))
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
} }
else
// JSON-LD for schema.org
var jsonLd = new
{ {
@context = "https://schema.org", metaTags.AppendLine($"<meta name=\"twitter:card\" content=\"{TwitterCardType}\">");
@type = "WebPage", if (!string.IsNullOrWhiteSpace(MetaImage))
name = MetaTitle, metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
description = MetaDescription, }
url = MetaUrl,
author = new
{
@type = "Person",
name = MetaAuthor,
url = MetaUrl
},
publisher = new
{
@type = "Organization",
name = MetaAuthor,
url = MetaUrl
},
mainEntityOfPage = new
{
@type = "WebPage",
@id = MetaUrl
},
inLanguage = "en",
datePublished = ArticlePublishedTime?.ToString("yyyy-MM-dd"),
dateModified = ArticleModifiedTime?.ToString("yyyy-MM-dd"),
headline = MetaTitle
};
string jsonLdString = JsonSerializer.Serialize(jsonLd, new JsonSerializerOptions
{
WriteIndented = true
});
metaTags.AppendLine($"<script type=\"application/ld+json\">{jsonLdString}</script>");
// Output all content // Output all content
output.Content.SetHtmlContent(metaTags.ToString()); output.Content.SetHtmlContent(metaTags.ToString());
} }
#endregion Public Methods
} }
} }

View File

@ -30,6 +30,10 @@ namespace CatherineLynwood.TagHelpers
[HtmlAttributeName("display-width-percentage")] [HtmlAttributeName("display-width-percentage")]
public int DisplayWidthPercentage { get; set; } = 100; public int DisplayWidthPercentage { get; set; } = 100;
[HtmlAttributeName("no-index")]
public bool NoIndex { get; set; }
private readonly string[] _resolutions = { "1920", "1400", "1200", "992", "768", "576" }; private readonly string[] _resolutions = { "1920", "1400", "1200", "992", "768", "576" };
private readonly string _wwwRoot = "wwwroot"; private readonly string _wwwRoot = "wwwroot";
@ -87,7 +91,10 @@ namespace CatherineLynwood.TagHelpers
} }
// Add the default WebP fallback in the <img> tag (JPEG is not included in HTML) // Add the default WebP fallback in the <img> tag (JPEG is not included in HTML)
output.Content.AppendHtml($"<img src='/images/webp/{fileName}-400.webp' class='{CssClass}' alt='{AltText}'>"); string noIndexAttr = NoIndex ? " data-nosnippet" : "";
output.Content.AppendHtml(
$"<img src='/images/webp/{fileName}-400.webp' class='{CssClass}' alt='{AltText}'{noIndexAttr}>");
} }

View File

@ -22,18 +22,18 @@ namespace CatherineLynwood.TagHelpers
var shareHtml = $@" var shareHtml = $@"
<div class='share-section'> <div class='share-section'>
<p>Enjoyed this page? Share it:</p> <p>Enjoyed this page? Share it:</p>
<a href='{facebookUrl}' target='_blank' rel='noopener' class='share-button facebook'><i class='fab fa-facebook-square'></i></a> <a href='{facebookUrl}' target='_blank' rel='noopener' class='share-button facebook' aria-label='Facebook share'><i class='fab fa-facebook-square'></i></a>
<a href='{twitterUrl}' target='_blank' rel='noopener' class='share-button twitter'><i class='fab fa-twitter'></i></a> <a href='{twitterUrl}' target='_blank' rel='noopener' class='share-button twitter' aria-label='X share'><i class='fab fa-twitter'></i></a>
<a href='{linkedinUrl}' target='_blank' rel='noopener' class='share-button linkedin'><i class='fab fa-linkedin'></i></a> <a href='{linkedinUrl}' target='_blank' rel='noopener' class='share-button linkedin' aria-label='LinkedIn share'><i class='fab fa-linkedin'></i></a>
<a href='{pinterestUrl}' target='_blank' rel='noopener' class='share-button pinterest'><i class='fab fa-pinterest-square'></i></a> <a href='{pinterestUrl}' target='_blank' rel='noopener' class='share-button pinterest' aria-label='Pintesest share'><i class='fab fa-pinterest-square'></i></a>
</div>"; </div>";
var followHtml = @" var followHtml = @"
<div class='follow-section mt-3'> <div class='follow-section mt-3'>
<small>Want to follow Catherine Lynwood?</small><br/> <small>Want to follow Catherine Lynwood?</small><br/>
<a href='https://twitter.com/@cathlynwood' target='_blank' rel='noopener' class='follow-icon'><i class='fab fa-twitter'></i></a> <a href='https://twitter.com/@cathlynwood' target='_blank' rel='noopener' class='follow-icon' aria-label='Follow on X'><i class='fab fa-twitter'></i></a>
<a href='https://www.linkedin.com/in/catherine-lynwood-73978433a' target='_blank' rel='noopener' class='follow-icon'><i class='fab fa-linkedin'></i></a> <a href='https://www.linkedin.com/in/catherine-lynwood-73978433a' target='_blank' rel='noopener' class='follow-icon' aria-label='Follow on LinkedIn'><i class='fab fa-linkedin'></i></a>
<a href='https://www.tiktok.com/@catherinelynwood' target='_blank' rel='noopener' class='follow-icon'><i class='fab fa-tiktok'></i></a> <a href='https://www.tiktok.com/@catherinelynwood' target='_blank' rel='noopener' class='follow-icon' aria-label='Follow on TikTok'><i class='fab fa-tiktok'></i></a>
</div>"; </div>";
output.TagName = "div"; output.TagName = "div";

View File

@ -8,7 +8,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="DIscovery">Discovery</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item active" aria-current="page">Chapter 1 - Drowning in Silence</li> <li class="breadcrumb-item active" aria-current="page">Chapter 1 - Drowning in Silence</li>
</ol> </ol>
</nav> </nav>
@ -77,6 +77,12 @@
</div> </div>
</div> </div>
@section Scripts{
<script>
const player = new Plyr('audio');
</script>
}
@section Meta { @section Meta {
<MetaTag meta-title="Chapter 1: Beth - The Alpha Flame by Catherine Lynwood" <MetaTag meta-title="Chapter 1: Beth - The Alpha Flame by Catherine Lynwood"
meta-description="Explore Chapter 1 of 'The Alpha Flame' by Catherine Lynwood. Discover Maggie's captivating story, full of determination and secrets, set in the vivid 1980s." meta-description="Explore Chapter 1 of 'The Alpha Flame' by Catherine Lynwood. Discover Maggie's captivating story, full of determination and secrets, set in the vivid 1980s."

View File

@ -8,7 +8,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="DIscovery">Discovery</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item active" aria-current="page">Chapter 13 - A Name She Never Owned</li> <li class="breadcrumb-item active" aria-current="page">Chapter 13 - A Name She Never Owned</li>
</ol> </ol>
</nav> </nav>
@ -93,6 +93,12 @@
</div> </div>
</div> </div>
@section Scripts{
<script>
const player = new Plyr('audio');
</script>
}
@section Meta { @section Meta {
<MetaTag meta-title="Chapter 2: Maggie - The Alpha Flame by Catherine Lynwood" <MetaTag meta-title="Chapter 2: Maggie - The Alpha Flame by Catherine Lynwood"
meta-description="Explore Chapter 13 of 'The Alpha Flame' by Catherine Lynwood. Discover Susie's captivating story, full of determination and secrets, set in the vivid 1980s." meta-description="Explore Chapter 13 of 'The Alpha Flame' by Catherine Lynwood. Discover Susie's captivating story, full of determination and secrets, set in the vivid 1980s."

View File

@ -8,7 +8,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="DIscovery">Discovery</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item active" aria-current="page">Chapter 2 - The Last Lesson</li> <li class="breadcrumb-item active" aria-current="page">Chapter 2 - The Last Lesson</li>
</ol> </ol>
</nav> </nav>
@ -76,6 +76,12 @@
</div> </div>
</div> </div>
@section Scripts{
<script>
const player = new Plyr('audio');
</script>
}
@section Meta { @section Meta {
<MetaTag meta-title="Chapter 2: Maggie - The Alpha Flame by Catherine Lynwood" <MetaTag meta-title="Chapter 2: Maggie - The Alpha Flame by Catherine Lynwood"
meta-description="Explore Chapter 2 of 'The Alpha Flame' by Catherine Lynwood. Discover Maggie's captivating story, full of determination and secrets, set in the vivid 1980s." meta-description="Explore Chapter 2 of 'The Alpha Flame' by Catherine Lynwood. Discover Maggie's captivating story, full of determination and secrets, set in the vivid 1980s."

View File

@ -8,8 +8,8 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Extras">Extras</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Extras">Discovery</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active" aria-current="page">Epilogue</li> <li class="breadcrumb-item active" aria-current="page">Epilogue</li>
</ol> </ol>
</nav> </nav>
@ -26,12 +26,8 @@
<!-- Audio and Text --> <!-- Audio and Text -->
<div class="col-12"> <div class="col-12">
<div class="bg-white rounded-5 border border-3 border-dark shadow-lg p-3"> <div class="bg-white rounded-5 border border-3 border-dark shadow-lg p-3">
<div class="hero-video-container"> <responsive-image src="discovery-epilogue.png" class="card-img-top" alt="The Gang Having a Drink at The Barnt Green Inn" display-width-percentage="100"></responsive-image>
<video autoplay muted loop playsinline poster="/images/discovery-epilogue.png">
<source src="/videos/discovery-epilogue.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
<!-- Audio Player --> <!-- Audio Player -->
<div class="audio-player text-center"> <div class="audio-player text-center">

View File

@ -11,6 +11,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item active" aria-current="page"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item active" aria-current="page"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item active" aria-current="page"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item active" aria-current="page">Extras</li> <li class="breadcrumb-item active" aria-current="page">Extras</li>
</ol> </ol>
</nav> </nav>
@ -20,9 +21,11 @@
<div class="container mt-5"> <div class="container mt-5">
<h1 class="extras-header">Your Exclusive Extras</h1> <h1 class="extras-header">Your Exclusive Extras</h1>
@if (accessLevel >= 1) @if (accessBook == 1)
{ {
<div class="extras-grid mt-4"> <div class="extras-grid mt-4">
@if (accessLevel >= 1)
{
<div class="card extra-card"> <div class="card extra-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Epilogue</h5> <h5 class="card-title">Epilogue</h5>
@ -30,7 +33,9 @@
<a asp-controller="Discovery" asp-action="Epilogue" class="btn btn-dark btn-sm">Read or Listen</a> <a asp-controller="Discovery" asp-action="Epilogue" class="btn btn-dark btn-sm">Read or Listen</a>
</div> </div>
</div> </div>
}
@if (accessLevel >= 2)
{
<div class="card extra-card"> <div class="card extra-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Discovery Scrap Book</h5> <h5 class="card-title">Discovery Scrap Book</h5>
@ -38,15 +43,19 @@
<a asp-controller="Discovery" asp-action="ScrapBook" class="btn btn-dark btn-sm">View Scrapbook</a> <a asp-controller="Discovery" asp-action="ScrapBook" class="btn btn-dark btn-sm">View Scrapbook</a>
</div> </div>
</div> </div>
}
@if (accessLevel >= 3)
{
<div class="card extra-card"> <div class="card extra-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Rubery Hill Photo Archive</h5> <h5 class="card-title">Listen to The Alpha Flame: Discovery</h5>
<p class="card-text">Explore historical photos and floor plans of the real Rubery Hill Hospital, the eerie inspiration behind key scenes.</p> <p class="card-text">Because you've purchased a premium physical copy of The Alpha Flame: Discovery, for a limited time this entitles you to listen to the audio version with no extra charge.</p>
<a href="/extras/rubery-hill-photos" class="btn btn-dark btn-sm">Explore</a> <a asp-controller="Discovery" asp-action="Listen" class="btn btn-dark btn-sm">Listen to the Book</a>
</div> </div>
</div> </div>
}
@if (accessLevel >= 4)
{
<div class="card extra-card"> <div class="card extra-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Scrapbook: Maggies Designs</h5> <h5 class="card-title">Scrapbook: Maggies Designs</h5>
@ -54,12 +63,29 @@
<a asp-controller="Discovery" asp-action="MaggiesDesigns" class="btn btn-dark btn-sm">View Scrapbook</a> <a asp-controller="Discovery" asp-action="MaggiesDesigns" class="btn btn-dark btn-sm">View Scrapbook</a>
</div> </div>
</div> </div>
}
</div> </div>
}
else if (accessBook == 2)
{
<div class="extras-grid mt-4">
@if (accessLevel >= 1)
{
} }
else if (accessLevel >= 2) else if (accessLevel >= 2)
{ {
<div class="extras-grid mt-4">
}
else if (accessLevel >= 3)
{
}
else if(accessLevel >= 4)
{
}
<div class="card extra-card"> <div class="card extra-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Epilogue</h5> <h5 class="card-title">Epilogue</h5>
@ -94,7 +120,7 @@
</div> </div>
} }
else if (accessLevel >= 3) else if (accessBook == 3)
{ {
<div class="extras-grid mt-4"> <div class="extras-grid mt-4">
<div class="card extra-card"> <div class="card extra-card">

View File

@ -145,6 +145,11 @@
} }
}); });
</script> </script>
<script>
const player = new Plyr('audio');
</script>
<script> <script>
fetch('https://ipapi.co/json/') fetch('https://ipapi.co/json/')
.then(response => response.json()) .then(response => response.json())

View File

@ -0,0 +1,628 @@
@{
ViewData["Title"] = "Listen to The Alpha Flame: Discovery";
}
<div class="row">
<div class="col-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active" aria-current="page">Listen</li>
</ol>
</nav>
</div>
</div>
<div class="container">
<div class="row">
<div class="col">
<p class="h3 text-center" id="status">Loading...</p>
<ul class="pagination justify-content-center" id="chapter-list"></ul>
<audio id="audio" preload="none"></audio>
<div class="input-group border border-2 border-dark rounded-3">
<!-- Progress Bar (styled to look like an input) -->
<div class="form-control p-0" style="height: 40px;">
<div id="chapter-progress" class="progress rounded-end-0" style="height: 100%; margin: 0;">
<div id="chapter-progress-bar" class="progress-bar rounded-end-0" role="progressbar" style="width: 0%"></div>
<div id="chapter-ticks" class="d-none d-md-block"></div>
</div>
</div>
<!-- Play / Pause Button -->
<button id="toggle-play" class="btn btn-primary" style="height: 40px;">
<i class="fad fa-pause"></i>
</button>
</div>
<div class="mt-1 mb-2">
<div class="progress border border-2 border-dark rounded-3" style="height: 12px;">
<div id="buffer-progress-bar" class="progress-bar bg-primary" style="width: 0%"></div>
</div>
</div>
<div id="chapter-timestamp" class="my-2 small text-center"></div>
<div id="chapter-text-container" class="chapter-text-scroll">
<div id="chapter-text-content"></div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
const audioBlobCache = new Map(); // id => Blob
const audioBlobSizes = new Map(); // id => size in bytes
let totalBlobSize = 0;
const MAX_BLOB_CACHE_SIZE = 100 * 1024 * 1024; // 100 MB
const BUFFER_TARGET_SECONDS = 15 * 60; // 30 minutes
let masterPlaylist = [];
let chapterStartIndices = [];
let currentIndex = 0;
let preloadedAudio = null;
const audio = document.getElementById("audio");
const status = document.getElementById("status");
const toggleButton = document.getElementById("toggle-play");
// Load the playlist
fetch("/api/audio/playlist/all")
.then(res => res.json())
.then(data => {
// ✅ Sort chapters numerically by chapter number
data.sort((a, b) => {
const aNum = parseInt(a.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
const bNum = parseInt(b.chapter.match(/Chapter_(\d+)/i)?.[1] || "0", 10);
return aNum - bNum;
});
let flatIndex = 0;
chapterStartIndices = []; // global or higher scoped variable
masterPlaylist = []; // reset in case of reload
data.forEach(chapter => {
chapterStartIndices.push({ index: flatIndex, name: chapter.chapter });
chapter.segments.forEach(segment => {
masterPlaylist.push({
id: segment.id,
display: segment.display,
chapter: chapter.chapter,
duration: parseDuration(segment.display),
text: segment.text || ""
});
flatIndex++;
});
});
// ✅ Determine saved/resume position
const savedIndex = parseInt(localStorage.getItem("lastAudioIndex") || "0", 10);
const currentSeg = masterPlaylist[savedIndex] || {};
const currentChapter = currentSeg.chapter || chapterStartIndices[0].name;
// ✅ Ensure pagination is rendered properly
renderChapterPagination(currentChapter);
status.textContent = `Ready ${masterPlaylist.length} segments total.`;
// ✅ Preload first segments of each chapter (for snappy chapter switches)
preloadFirstSegments();
// ✅ Preload 10 minutes of sequential audio starting from resume position or beginning
const preloadStart = (savedIndex >= 0 && savedIndex < masterPlaylist.length)
? savedIndex
: 0;
preloadSegmentsAhead(preloadStart, BUFFER_TARGET_SECONDS); // preload 10 mins ahead
// ✅ Resume if index is valid
if (savedIndex >= 0 && savedIndex < masterPlaylist.length) {
const seg = masterPlaylist[savedIndex];
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(savedIndex);
}
});
function fetchBlobWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch blob ${url}:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterPagination(currentChapter) {
let listHtml = '';
const currentPos = chapterStartIndices.findIndex(c => c.name === currentChapter);
const totalChapters = chapterStartIndices.length;
const windowSize = 5;
const halfWindow = Math.floor(windowSize / 2);
// Previous button
listHtml += `<li class="page-item ${currentPos <= 0 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToPrevChapter(${currentPos})"><i class="fad fa-step-backward"></i></a>
</li>`;
const addPageItem = (i, label = null) => {
const chapterNumber = chapterStartIndices[i].name.match(/Chapter_(\d+)/i)?.[1] || "?";
const isActive = i === currentPos;
listHtml += `<li class="page-item ${isActive ? 'active' : ''}">
<a class="page-link" href="#" onclick="startFrom(${chapterStartIndices[i].index})">${label || chapterNumber}</a>
</li>`;
};
// First button
addPageItem(0);
// Ellipsis after first if needed
if (currentPos - halfWindow > 1) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Sliding window
const start = Math.max(1, currentPos - halfWindow);
const end = Math.min(totalChapters - 2, currentPos + halfWindow);
for (let i = start; i <= end; i++) {
if (i !== 0 && i !== totalChapters - 1) {
addPageItem(i);
}
}
// Ellipsis before last if needed
if (currentPos + halfWindow < totalChapters - 2) {
listHtml += `<li class="page-item disabled d-none d-md-block"><span class="page-link">…</span></li>`;
}
// Last button
if (totalChapters > 1) {
addPageItem(totalChapters - 1);
}
// Next button
listHtml += `<li class="page-item ${currentPos >= totalChapters - 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToNextChapter(${currentPos})"><i class="fad fa-step-forward"></i></a>
</li>`;
document.getElementById("chapter-list").innerHTML = listHtml;
}
function goToPrevChapter(currentPos) {
if (currentPos <= 0) return;
const newIndex = chapterStartIndices[currentPos - 1].index;
const newChapter = chapterStartIndices[currentPos - 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function goToNextChapter(currentPos) {
if (currentPos >= chapterStartIndices.length - 1) return;
const newIndex = chapterStartIndices[currentPos + 1].index;
const newChapter = chapterStartIndices[currentPos + 1].name;
renderChapterPagination(newChapter);
startFrom(newIndex);
}
function parseDuration(display) {
const parts = display.split(":").map(Number);
return parts[0] * 60 + (parts[1] || 0);
}
// Call this in startFrom()
function startFrom(index) {
const seg = masterPlaylist[index];
// Ensure autoplay works (helps with Chrome mobile restrictions)
audio.muted = false;
audio.autoplay = true;
renderChapterPagination(seg.chapter);
renderChapterText(seg.chapter);
initChapterProgress(seg.chapter);
loadSegment(index);
}
function loadSegment(index) {
if (index < 0 || index >= masterPlaylist.length) return;
currentIndex = index;
updateBufferBar(index);
localStorage.setItem("lastAudioIndex", index.toString());
const seg = masterPlaylist[index];
// ✅ Scroll and highlight current paragraph
document.querySelectorAll(".chapter-text-scroll p").forEach(p => p.classList.remove("active"));
const activePara = document.getElementById(`text-seg-${index}`);
if (activePara) {
activePara.classList.add("active");
const container = document.querySelector(".chapter-text-scroll");
const paraTop = activePara.offsetTop;
const paraHeight = activePara.offsetHeight;
const containerHeight = container.clientHeight;
const scrollTarget = paraTop - container.offsetTop - (containerHeight / 2) + (paraHeight / 2);
container.scrollTo({ top: scrollTarget, behavior: "smooth" });
}
const tryPlay = async (blob, retryCount = 0) => {
try {
const blobUrl = URL.createObjectURL(blob);
audio.src = blobUrl;
await audio.play();
status.textContent = `Playing: ${seg.chapter.replace(/_/g, ' ')}`;
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} catch (err) {
if (retryCount < 10) {
console.warn(`Retrying audio playback (${retryCount + 1})...`);
setTimeout(() => tryPlay(blob, retryCount + 1), 3000);
} else {
console.error("Playback failed permanently:", err);
status.textContent = "Error loading audio segment.";
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
}
};
const fetchAndPlay = async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 5, 2000);
const blob = await fetch(response).then(r => r.blob());
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
console.error("Initial fetch failed, will retry in background:", err);
status.textContent = "Waiting for signal...";
let retries = 0;
const maxRetries = 12;
const retryLoop = setInterval(async () => {
try {
const response = await fetchWithRetry(`/api/audio/token-url/${seg.id}`, 3, 2000);
const blob = await fetch(response).then(r => r.blob());
clearInterval(retryLoop);
audioBlobCache.set(seg.id, blob);
tryPlay(blob);
} catch (err) {
retries++;
console.warn(`Retry ${retries}/${maxRetries} failed`);
if (retries >= maxRetries) {
clearInterval(retryLoop);
status.textContent = "Unable to load segment.";
}
}
}, 10000);
}
};
if (audioBlobCache.has(seg.id)) {
tryPlay(audioBlobCache.get(seg.id));
} else {
fetchAndPlay();
}
preloadSegmentsAhead(index + 1, BUFFER_TARGET_SECONDS);
}
function preloadSegmentsAhead(startIndex, targetSeconds) {
let totalDuration = 0;
let i = startIndex;
while (i < masterPlaylist.length && totalDuration < targetSeconds) {
const seg = masterPlaylist[i];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn("Preload failed:", err));
}
totalDuration += seg.duration;
i++;
}
}
function fetchWithRetry(url, retries = 3, delayMs = 1000) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(resolve)
.catch(err => {
if (n > 0) {
setTimeout(() => attempt(n - 1), delayMs);
} else {
console.warn(`Failed to fetch ${url} after retries:`, err);
reject(err);
}
});
};
attempt(retries);
});
}
function renderChapterText(chapterName) {
const container = document.getElementById("chapter-text-content");
container.innerHTML = "";
masterPlaylist.forEach((seg, i) => {
if (seg.chapter === chapterName) {
const p = document.createElement("p");
p.textContent = seg.text || "";
p.id = `text-seg-${i}`;
container.appendChild(p);
}
});
}
let currentChapter = "";
let chapterSegments = [];
let chapterTotalDuration = 0;
function initChapterProgress(chapterName) {
currentChapter = chapterName;
chapterSegments = masterPlaylist.filter(s => s.chapter === chapterName);
chapterTotalDuration = chapterSegments.reduce((sum, seg) => sum + seg.duration, 0);
updateProgressUI(0);
renderChapterTicks(); // ✅ Added here
}
function updateProgressUI(elapsedSeconds) {
const percent = (elapsedSeconds / chapterTotalDuration) * 100;
document.getElementById("chapter-progress-bar").style.width = `${percent}%`;
const mins = Math.floor(elapsedSeconds / 60).toString().padStart(2, "0");
const secs = Math.floor(elapsedSeconds % 60).toString().padStart(2, "0");
const totalMins = Math.floor(chapterTotalDuration / 60).toString().padStart(2, "0");
const totalSecs = Math.floor(chapterTotalDuration % 60).toString().padStart(2, "0");
document.getElementById("chapter-timestamp").textContent = `${mins}:${secs} / ${totalMins}:${totalSecs}`;
}
function getElapsedInChapter(currentIdx, audioCurrentTime) {
let sum = 0;
for (let i = 0; i < currentIdx; i++) {
if (masterPlaylist[i].chapter === currentChapter) {
sum += masterPlaylist[i].duration;
}
}
return sum + audioCurrentTime;
}
function renderChapterTicks() {
const ticksContainer = document.getElementById("chapter-ticks");
ticksContainer.innerHTML = "";
let elapsed = 0;
chapterSegments.forEach(seg => {
const percent = (elapsed / chapterTotalDuration) * 100;
const tick = document.createElement("div");
tick.className = "chapter-tick";
tick.style.left = `${percent}%`;
ticksContainer.appendChild(tick);
// const label = document.createElement("div");
// label.className = "chapter-tick-label";
// label.style.left = `${percent}%`;
// label.textContent = seg.display;
// ticksContainer.appendChild(label);
elapsed += seg.duration;
});
}
function updatePaginationHighlight(currentChapter) {
document.querySelectorAll("#chapter-list .page-item").forEach((li, idx) => {
const chapterNum = parseInt(li.textContent);
const currentNum = parseInt(currentChapter.match(/Chapter_(\d+)/i)?.[1]);
if (chapterNum === currentNum) {
li.classList.add("active");
} else {
li.classList.remove("active");
}
});
}
function preloadFirstSegments() {
chapterStartIndices.forEach(({ index }) => {
const seg = masterPlaylist[index];
if (!audioBlobCache.has(seg.id)) {
fetch(`/api/audio/token-url/${seg.id}`)
.then(res => res.text())
.then(url => fetchBlobWithRetry(url))
.then(blob => {
const blobSize = blob.size;
audioBlobCache.set(seg.id, blob);
audioBlobSizes.set(seg.id, blobSize);
totalBlobSize += blobSize;
enforceBlobCacheLimit();
updateBufferBar(currentIndex);
})
.catch(err => console.warn(`Preload of chapter start failed (${seg.id}):`, err));
}
});
}
function enforceBlobCacheLimit() {
const iterator = audioBlobCache.keys();
while (totalBlobSize > MAX_BLOB_CACHE_SIZE && audioBlobCache.size > 0) {
const oldestKey = iterator.next().value;
const size = audioBlobSizes.get(oldestKey) || 0;
audioBlobCache.delete(oldestKey);
audioBlobSizes.delete(oldestKey);
totalBlobSize -= size;
console.log(`Evicted ${oldestKey} to maintain cache size. Remaining size: ${totalBlobSize} bytes.`);
}
}
function updateBufferBar(currentIdx) {
let bufferedSeconds = 0;
let totalChecked = 0;
let i = currentIdx + 1;
while (i < masterPlaylist.length && totalChecked < BUFFER_TARGET_SECONDS) {
const seg = masterPlaylist[i];
totalChecked += seg.duration;
if (audioBlobCache.has(seg.id)) {
bufferedSeconds += seg.duration;
}
i++;
}
const percent = Math.min((bufferedSeconds / BUFFER_TARGET_SECONDS) * 100, 100);
const bar = document.getElementById("buffer-progress-bar");
bar.style.width = `${percent.toFixed(1)}%`;
bar.setAttribute("aria-valuenow", percent.toFixed(1));
// ✅ Set Bootstrap colour classes
bar.classList.remove("bg-primary", "bg-warning", "bg-danger");
if (percent >= 75) {
bar.classList.add("bg-primary");
} else if (percent >= 30) {
bar.classList.add("bg-warning");
} else {
bar.classList.add("bg-danger");
}
}
audio.addEventListener("ended", () => {
if (currentIndex < masterPlaylist.length - 1) {
const nextIndex = currentIndex + 1;
const nextSeg = masterPlaylist[nextIndex];
const currentChapter = masterPlaylist[currentIndex].chapter;
const nextChapter = nextSeg.chapter;
if (nextChapter !== currentChapter) {
renderChapterPagination(nextChapter); // ✅ Update pagination
renderChapterText(nextChapter);
initChapterProgress(nextChapter);
}
loadSegment(nextIndex);
} else {
status.textContent = "Playback complete.";
}
});
// Update during playback
audio.addEventListener("timeupdate", () => {
if (!chapterSegments.length || !audio.duration) return;
const elapsed = getElapsedInChapter(currentIndex, audio.currentTime);
updateProgressUI(elapsed);
});
toggleButton.addEventListener("click", () => {
if (audio.paused) {
audio.play();
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
} else {
audio.pause();
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
}
});
audio.addEventListener("play", () => {
toggleButton.innerHTML = '<i class="fad fa-pause"></i>';
});
audio.addEventListener("pause", () => {
toggleButton.innerHTML = '<i class="fad fa-play"></i>';
});
document.getElementById("chapter-progress").addEventListener("click", (e) => {
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickPercent = clickX / rect.width;
const targetTime = clickPercent * chapterTotalDuration;
// Find approximate segment
let cumulative = 0;
for (let i = 0; i < chapterSegments.length; i++) {
const seg = chapterSegments[i];
if (cumulative + seg.duration >= targetTime) {
const globalIndex = masterPlaylist.findIndex(s => s.id === seg.id);
if (globalIndex >= 0) loadSegment(globalIndex);
break;
}
cumulative += seg.duration;
}
});
</script>
}

View File

@ -7,8 +7,8 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Extras">Extras</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Extras">Discovery</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active" aria-current="page">Maggie's Designs</li> <li class="breadcrumb-item active" aria-current="page">Maggie's Designs</li>
</ol> </ol>
</nav> </nav>

View File

@ -7,8 +7,8 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Index">The Alpha Flame</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Extras">Extras</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Index">Discovery</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Extras">Discovery</a></li> <li class="breadcrumb-item"><a asp-controller="Discovery" asp-action="Extras">Extras</a></li>
<li class="breadcrumb-item active" aria-current="page">Discovery Scrap Book</li> <li class="breadcrumb-item active" aria-current="page">Discovery Scrap Book</li>
</ol> </ol>
</nav> </nav>

View File

@ -15,7 +15,7 @@
<div class="row align-items-center mb-5"> <div class="row align-items-center mb-5">
<div class="col-md-5 text-center"> <div class="col-md-5 text-center">
<a asp-controller="TheAlphaFlame" asp-action="Discovery"> <a asp-controller="Discovery" asp-action="Index">
<div class="hero-video-container"> <div class="hero-video-container">
<video id="heroVideo" autoplay muted loop playsinline preload="none" poster="/images/webp/the-alpha-flame-discovery-blank-400.webp"> <video id="heroVideo" autoplay muted loop playsinline preload="none" poster="/images/webp/the-alpha-flame-discovery-blank-400.webp">
<!-- Source will be injected later --> <!-- Source will be injected later -->
@ -25,10 +25,10 @@
<responsive-image src="the-alpha-flame-discovery-overlay.png" <responsive-image src="the-alpha-flame-discovery-overlay.png"
class="hero-overlay-image" class="hero-overlay-image"
alt="The Alpha Flame: Discovery by Catherine Lynwood" alt="The Alpha Flame: Discovery by Catherine Lynwood"
display-width-percentage="50"> display-width-percentage="50"
no-index="true">
</responsive-image> </responsive-image>
</div> </div>
</a> </a>
</div> </div>
@ -39,8 +39,8 @@
When Maggie Grant finds Beth, bruised, terrified, and alone, she has no idea the rescue will ignite a chain of secrets buried deep in her own past... When Maggie Grant finds Beth, bruised, terrified, and alone, she has no idea the rescue will ignite a chain of secrets buried deep in her own past...
</p> </p>
<div class="d-flex gap-3 flex-wrap"> <div class="d-flex gap-3 flex-wrap">
<a href="/the-alpha-flame/discovery" class="btn btn-dark">Explore the Book</a> <a asp-controller="Discovery" asp-action="Index" class="btn btn-dark">Explore the Book</a>
<a href="/the-alpha-flame/extras" class="btn btn-outline-dark">Unlock Extras</a> <a asp-controller="Discovery" asp-action="Extras" class="btn btn-outline-dark">Unlock Extras</a>
</div> </div>
<p class="mt-4"> <p class="mt-4">
<em>The Alpha Flame: Discovery</em>, writen by <a asp-controller="Home" asp-action="AboutCatherineLynwood" class="link-dark fw-semibold">Catherine Lynwood</a>, is the first in a powerful trilogy following the tangled lives of Maggie and Beth , two women bound by fate, fire, and secrets too dangerous to stay buried. <em>The Alpha Flame: Discovery</em>, writen by <a asp-controller="Home" asp-action="AboutCatherineLynwood" class="link-dark fw-semibold">Catherine Lynwood</a>, is the first in a powerful trilogy following the tangled lives of Maggie and Beth , two women bound by fate, fire, and secrets too dangerous to stay buried.
@ -53,11 +53,19 @@
@section Meta { @section Meta {
<meta name="description" content="Explore the captivating world of Catherine Lynwoods *The Alpha Flame*, a powerful novel blending romance, mystery, and family secrets set in the 1980s. Follow twin sisters as they unravel hidden truths and face emotional challenges. Discover more about Catherine Lynwoods storytelling and insights on the official website."> <MetaTag meta-title="Catherine Lynwood Author of The Alpha Flame Trilogy"
meta-description="Discover Catherine Lynwoods gripping trilogy, *The Alpha Flame*, set in 1983 Birmingham. Follow the journey of two sisters as secrets unravel and destinies collide."
<meta name="keywords" content="Catherine Lynwood, The Alpha Flame novel, women's fiction, 1980s drama book, family secrets novel, twin sisters story, romance and suspense fiction, strong female characters, womens novels, Catherine Lynwood blog, mystery novels for women, female protagonist book, family thriller"> meta-keywords="Catherine Lynwood, The Alpha Flame, historical fiction, 1983 novel, Birmingham author, twin sisters, suspense trilogy, female-led fiction"
meta-author="Catherine Lynwood"
<meta name="author" content="Catherine Lynwood"> meta-url="https://www.catherinelynwood.com/"
meta-image="https://www.catherinelynwood.com/images/webp/the-alpha-flame-11-600.webp"
meta-image-alt="Cover artwork from 'The Alpha Flame: Discovery' by Catherine Lynwood"
og-site-name="Catherine Lynwood The Alpha Flame"
article-published-time="@new DateTime(2024, 11, 20)"
article-modified-time="@new DateTime(2025, 06, 17)"
twitter-card-type="summary_large_image"
twitter-site-handle="@@CathLynwood"
twitter-creator-handle="@@CathLynwood" />
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@ -65,7 +73,6 @@
"@@type": "WebSite", "@@type": "WebSite",
"url": "https://www.catherinelynwood.com", "url": "https://www.catherinelynwood.com",
"name": "Catherine Lynwood Official Website", "name": "Catherine Lynwood Official Website",
"description": "The official website of Catherine Lynwood, author of *The Alpha Flame*, featuring novels with strong female characters, romance, and mystery.",
"publisher": { "publisher": {
"@@type": "Person", "@@type": "Person",
"name": "Catherine Lynwood" "name": "Catherine Lynwood"
@ -73,15 +80,14 @@
} }
</script> </script>
<!-- Person -->
<script type="application/ld+json"> <script type="application/ld+json">
{ {
"@@context": "https://schema.org", "@@context": "https://schema.org",
"@@type": "Person", "@@type": "Person",
"name": "Catherine Lynwood", "name": "Catherine Lynwood",
"url": "https://www.catherinelynwood.com", "url": "https://www.catherinelynwood.com",
"jobTitle": "Author", "jobTitle": "Author"
"knowsAbout": ["womens fiction", "family secrets novels", "mystery"],
"knowsLanguage": "English"
} }
</script> </script>
@ -134,6 +140,8 @@
} }
</script> </script>
<script> <script>
window.addEventListener("load", () => { window.addEventListener("load", () => {
setTimeout(() => { setTimeout(() => {

View File

@ -31,7 +31,7 @@
</section> </section>
<div class="row justify-content-center p-3"> <div class="row justify-content-center p-3">
<div class="col-auto"> <div class="col-12 bg-white rounded-5">
<audio controls> <audio controls>
<source src="/audio/samantha-lynwood.mp3" type="audio/mpeg"> <source src="/audio/samantha-lynwood.mp3" type="audio/mpeg">
Your browser does not support the audio element. Your browser does not support the audio element.
@ -50,6 +50,12 @@
</div> </div>
@section Scripts{
<script>
const player = new Plyr('audio');
</script>
}
@section Meta { @section Meta {
<meta name="description" content="Learn about Samantha Lynwood, audio producer of *The Alpha Flame* podcasts, a compelling novel of romance, mystery, and family secrets. Discover Catherines inspirations, her journey as a novelist, and her commitment to stories featuring strong female characters and intricate plots."> <meta name="description" content="Learn about Samantha Lynwood, audio producer of *The Alpha Flame* podcasts, a compelling novel of romance, mystery, and family secrets. Discover Catherines inspirations, her journey as a novelist, and her commitment to stories featuring strong female characters and intricate plots.">
<meta name="keywords" content="Samantha Lynwood author, about Samantha Lynwood, Samantha Lynwood biography, womens fiction author, mystery and romance novelist, family secrets book author, female protagonists in fiction, Catherine Lynwood novels, womens novel writer, The Alpha Flame author, female fiction writers"> <meta name="keywords" content="Samantha Lynwood author, about Samantha Lynwood, Samantha Lynwood biography, womens fiction author, mystery and romance novelist, family secrets book author, female protagonists in fiction, Catherine Lynwood novels, womens novel writer, The Alpha Flame author, female fiction writers">

View File

@ -9,7 +9,7 @@
<link rel="stylesheet" href="~/css/fontawesome.min.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/fontawesome.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/brands.min.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/brands.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/duotone.min.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/duotone.min.css" asp-append-version="true" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.2/plyr.css" /> <link rel="stylesheet" href="~/css/plyr.min.css" asp-append-version="true" />
<style> <style>
.plyr--audio .plyr__controls { .plyr--audio .plyr__controls {
@ -74,7 +74,7 @@
<nav class="navbar fixed-top navbar-expand-sm navbar-toggleable-sm navbar-dark border-bottom border-2 border-primary box-shadow mb-3 bg-dark"> <nav class="navbar fixed-top navbar-expand-sm navbar-toggleable-sm navbar-dark border-bottom border-2 border-primary box-shadow mb-3 bg-dark">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index"> <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">
<img src="~/images/catherine-lynwood-banner-logo-small.png" alt="Catherine Lynwood Logo Banner" class="img-fluid" style="height: 50px;" /> <img src="~/images/catherine-lynwood-banner-logo-small.png" alt="Catherine Lynwood Logo Banner" class="img-fluid" style="height: 50px; width: 295px;" />
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation"> aria-expanded="false" aria-label="Toggle navigation">
@ -155,9 +155,9 @@
<script src="~/js/plyr.js"></script> <script src="~/js/plyr.js"></script>
<script> @* <script>
const player = new Plyr('audio'); const player = new Plyr('audio');
</script> </script> *@
@RenderSection("Scripts", required: false) @RenderSection("Scripts", required: false)
<script> <script>

View File

@ -27,7 +27,7 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<header> <header>
<h1 class="d-inline-block">@Model.Title</h1> <h2 class="h1 d-inline-block">@Model.SubTitle</h2> <h1 class="d-inline-block">@Model.Title</h1><br /><h2 class="h4 d-inline-block">@Model.SubTitle</h2>
<p>Posted on @Model.PublishDate.ToString("MMMM d, yyyy") by Catherine Lynwood</p> <p>Posted on @Model.PublishDate.ToString("MMMM d, yyyy") by Catherine Lynwood</p>
</header> </header>
</div> </div>
@ -35,7 +35,7 @@
@if (!string.IsNullOrWhiteSpace(Model.AudioTranscriptUrl)) @if (!string.IsNullOrWhiteSpace(Model.AudioTranscriptUrl))
{ {
<div class="row justify-content-center p-3"> <div class="row justify-content-center p-3">
<div class="col-auto"> <div class="col-md-6 bg-white rounded-5">
<audio controls> <audio controls>
<source src="/audio/@Model.AudioTranscriptUrl" type="audio/mpeg"> <source src="/audio/@Model.AudioTranscriptUrl" type="audio/mpeg">
Your browser does not support the audio element. Your browser does not support the audio element.
@ -120,6 +120,12 @@
</article> </article>
@section Scripts{
<script>
const player = new Plyr('audio');
</script>
}
@section Meta { @section Meta {
<meta name="description" content="Explore the blog of Catherine Lynwood, author of *The Alpha Flame*, for updates, insights, and behind-the-scenes stories. Follow Catherines journey as a writer, discover inspiration for her novels, and delve into discussions on womens fiction, mystery, and family secrets."> <meta name="description" content="Explore the blog of Catherine Lynwood, author of *The Alpha Flame*, for updates, insights, and behind-the-scenes stories. Follow Catherines journey as a writer, discover inspiration for her novels, and delve into discussions on womens fiction, mystery, and family secrets.">
<meta name="keywords" content="Catherine Lynwood blog, The Alpha Flame updates, womens fiction blog, author Catherine Lynwood insights, mystery novel blog, family secrets stories, strong female protagonists, 1980s fiction blog, book updates and stories, authors journey blog, Catherine Lynwood writing process"> <meta name="keywords" content="Catherine Lynwood blog, The Alpha Flame updates, womens fiction blog, author Catherine Lynwood insights, mystery novel blog, family secrets stories, strong female protagonists, 1980s fiction blog, book updates and stories, authors journey blog, Catherine Lynwood writing process">

View File

@ -13,7 +13,7 @@
</div> </div>
</div> </div>
<div class="container py-5"> <div class="container py-2">
<div class="text-center mb-5"> <div class="text-center mb-5">
<h1 class="display-5 fw-bold">The Alpha Flame Trilogy</h1> <h1 class="display-5 fw-bold">The Alpha Flame Trilogy</h1>
<p class="lead"> <p class="lead">
@ -69,9 +69,6 @@
<div class="col-12"> <div class="col-12">
<div class="card h-100 shadow-sm border-start border-3 border-dark position-relative"> <div class="card h-100 shadow-sm border-start border-3 border-dark position-relative">
<div class="row g-0"> <div class="row g-0">
<a asp-action="Characters">
</a>
<div class="col-md-8 d-none d-md-block"> <div class="col-md-8 d-none d-md-block">
<a asp-action="Characters"> <a asp-action="Characters">
<responsive-image src="discovery-epilogue.png" class="img-fluid rounded-start-3" alt="Meet The Characters" display-width-percentage="50"></responsive-image> <responsive-image src="discovery-epilogue.png" class="img-fluid rounded-start-3" alt="Meet The Characters" display-width-percentage="50"></responsive-image>

View File

@ -34,7 +34,7 @@
@if (!string.IsNullOrWhiteSpace(Model.AudioTranscriptUrl)) @if (!string.IsNullOrWhiteSpace(Model.AudioTranscriptUrl))
{ {
<div class="row justify-content-center p-3"> <div class="row justify-content-center p-3">
<div class="col-auto"> <div class="col-md-6 bg-white rounded-5">
<audio controls> <audio controls>
<source src="/audio/@Model.AudioTranscriptUrl" type="audio/mpeg"> <source src="/audio/@Model.AudioTranscriptUrl" type="audio/mpeg">
Your browser does not support the audio element. Your browser does not support the audio element.
@ -142,6 +142,12 @@
</div> </div>
</article> </article>
@section Scripts{
<script>
const player = new Plyr('audio');
</script>
}
@section Meta { @section Meta {
<meta name="description" content="Explore the blog of Catherine Lynwood, author of *The Alpha Flame*, for updates, insights, and behind-the-scenes stories. Follow Catherines journey as a writer, discover inspiration for her novels, and delve into discussions on womens fiction, mystery, and family secrets."> <meta name="description" content="Explore the blog of Catherine Lynwood, author of *The Alpha Flame*, for updates, insights, and behind-the-scenes stories. Follow Catherines journey as a writer, discover inspiration for her novels, and delve into discussions on womens fiction, mystery, and family secrets.">
<meta name="keywords" content="Catherine Lynwood blog, The Alpha Flame updates, womens fiction blog, author Catherine Lynwood insights, mystery novel blog, family secrets stories, strong female protagonists, 1980s fiction blog, book updates and stories, authors journey blog, Catherine Lynwood writing process"> <meta name="keywords" content="Catherine Lynwood blog, The Alpha Flame updates, womens fiction blog, author Catherine Lynwood insights, mystery novel blog, family secrets stories, strong female protagonists, 1980s fiction blog, book updates and stories, authors journey blog, Catherine Lynwood writing process">

View File

@ -6,7 +6,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li> <li class="breadcrumb-item"><a asp-controller="Home" asp-action="Index">Home</a></li>
<li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Blog">The Alpha Flame Blog</a></li> <li class="breadcrumb-item"><a asp-controller="TheAlphaFlame" asp-action="Blog">The Alpha Flame Blog</a></li>
<li class="breadcrumb-item active" aria-current="page">@Model.Title @Model.SubTitle</li> <li class="breadcrumb-item active" aria-current="page">@Model.Title</li>
</ol> </ol>
</nav> </nav>
</div> </div>

View File

@ -5,6 +5,10 @@
"Email": { "Email": {
"APIKey": "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" "APIKey": "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"
}, },
"AudioSecurity": {
"HmacSecretKey": "SG.7xaVKHzRQsS5os1IJUJZ2Q.2osFDJIRkjlDl3eM05uZ9R1IUA6Wv",
"TokenExpirySeconds": 86400
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

View File

@ -52,5 +52,11 @@
"inputFiles": [ "inputFiles": [
"wwwroot/js/plyr.js" "wwwroot/js/plyr.js"
] ]
},
{
"outputFileName": "wwwroot/css/plyr.min.css",
"inputFiles": [
"wwwroot/css/plyr.css"
]
} }
] ]

View File

@ -0,0 +1,64 @@
The Alpha Flame, by Catherine Lynwood
Chapter 1 - Drowning in Silence
Narrated by Beth 7th May 1980
Id never known silence like that before. The kind that creeps under your skin and settles in your bones, sinking in so deep it feels like it might smother you. When I opened the door, that silence wrapped itself around me, choking me, filling me up until there was nothing else. I didnt even know what I was seeing at first. I think maybe my mind tried to protect me, tried to shield me from what was right in front of me, even though I knew, deep down, that everything was about to change.
She was slumped there in the bath, water cold and still around her, her face as blank as a wax dolls, skin washed out, lifeless. The first thought I had, the thing Ill never forgive myself for, was how wrong it looked. It felt surreal, like some sort of sick joke. This wasnt her. It couldnt be. My mum wasnt a drinker, not like this, not ever, but there was an empty bottle lying on its side beside the bath, rolling slightly as I opened the door wider. It felt like it was mocking me, daring me to believe what I was seeing.
I felt sick, my throat clenching, my stomach twisting, and for a moment, I hated her, or whoever had done this to her. Hated the absurdity, the impossibility of it. Shed never have chosen that bottle over me, over herself. And yet there it was, an empty accusation, staring at me from the floor, her face pale and her lips blue. I couldnt make sense of it. I just stood there, a dead thing staring back at her, just as lifeless as she was.
They say your life flashes before your eyes when you die, but I think theyre wrong. I think its the people left behind, the ones who have to see it, who have to stand there, watching their entire world collapse around them. I saw everything; all the tiny pieces of a life shed held together for me, every smile, every reassuring word, every single thing that had kept me safe. And I realised, right then, that I was all alone. Utterly and completely alone.
Theres something that breaks in you when you lose everything in one heartbeat. Its like the walls inside you just give way, crumbling into nothing, until all thats left is this empty shell. I felt it, that shattering, like glass splintering into a million pieces inside my chest. I remember gripping the doorframe so hard my knuckles turned white, the pain grounding me, keeping me from slipping into whatever dark pit was opening up beneath my feet. I couldnt look away from her. I couldnt move, couldnt breathe. I was frozen, trapped in this nightmare that wouldnt end, a part of me hoping that if I stared long enough, Id wake up. That this would all just go away.
But it didnt. And I knew it wouldnt. Because that was the moment my life ended too. She may have been the one in the water, but I was drowning right along with her.
The weight of it pressed down on me, crushing me, stealing the air from my lungs until I felt like I was suffocating. I wanted to reach out, to touch her, to shake her awake and scream at her for leaving me. But my hand wouldnt move. I was terrified, terrified that if I touched her, it would make it real. Id have to face it, that she was gone. That shed left me behind. And I hated her for it, hated her so much in that moment that it felt like poison in my veins, eating me alive.
I dont know how long I stood there, staring at her lifeless face, feeling the last remnants of who I was slip away. There was no Beth anymore. No fifteen-year-old girl with a future, with dreams. That girl died in that bathroom, in the icy water and that strange, bitter smell of alcohol that felt wrong in every way. I felt like I was standing in a grave, my own grave, looking at the corpse of the only person whod ever loved me.
And then, the silence broke. It shattered around me, replaced by this horrible, waling sound that I didnt realise was coming from me. I was sobbing, ugly, desperate sobs that ripped through me, tearing me apart from the inside out. I sank to the floor, my legs giving out, and I cried like Id never cried before. It was like every ounce of pain Id ever felt, every lonely night, every broken promise shed left me with was pouring out, raw and brutal and endless.
I dont remember deciding to leave the bathroom. I dont remember making the choice to move. But somehow, my feet carried me forward, step after step, like I wasnt really there at all. Like my body knew what to do even when my mind didnt. All I remember is the empty, gaping void inside me, a darkness so vast it swallowed up everything that used to be me. I was a stranger to myself, hollow and numb, and I knew, God, I knew, that I would never be whole again. My life had ended in that bathroom, drowned alongside hers.
I sat there for what must have been hours. It was dark when I finally mustered the energy to move. Id been there since coming home from school. I was tired, hungry, thirsty, but none of that mattered. Nothing mattered.
Somehow, I found my way downstairs. I went out through the front door leaving it open, and walked next door. I rang the bell, and after an age, Mrs. Patterson answered the door. She took one look at me, and knew something terrible had happened.
“Beth. Whats wrong?”
As hard as I tried, I couldnt get the words out. The horror of the last few hours had torn its way into my brain and ripped away my ability to act as a rational human being. I just stared at her, tears rolling down my face.
“Beth. What is it? Whats wrong?” she persisted.
I just looked at her blankly, and mumbled, “Mum.”
What happened after that I couldnt really tell you. It was a blur. There were a lot of people, many of them in uniform, going all through my home, looking for God knows what.
I remember some of the neighbours stood around outside my house, staring, gawking, at the drama playing itself out in front of them; and then, in the early hours, the stretcher. I remember the pain, as I saw them carry my mums dead body out, wrapped in a plastic body bag. I saw it being loaded into a black private ambulance, and that was it, the last time I ever saw her.
I spent the first few days at Mrs. Pattersons house next door, floating in a fog that didnt lift. People came and went in low voices, speaking in clipped, polite tones that felt wrong in every way. I heard social workers talking to Mrs. Patterson in the hallway, their voices quiet, but not quiet enough. They spoke like I wasnt there, like my life was just another box on their checklist, something to be dealt with and filed away.
“Her mother left her with nothing,” one of them muttered, “Theres no father in the picture. Were looking at… alternative options.”
Alternative options… They spoke in circles around the word care; a word that didnt mean what they thought it did. Id heard the stories at school, kids who disappeared into those places, came out harder, colder, or didnt come out at all. The thought of it clawed at me, a raw, festering fear. My life had already shattered, and now strangers were picking through the pieces, deciding where I should go next, as if I didnt have any say in it.
I remember sitting there, hunched on Mrs. Pattersons sofa, while the social worker shuffled papers and spoke in that clipped, professional voice. They had been talking about me, around me, but not to me. Until now.
“So, do you know of any family you might like to go to, Beth?”
The question caught me off guard. They were actually asking me something? I blinked at her, unsure whether to feel relieved or suspicious.
“Well,” I started slowly, “Ive got an aunt somewhere. Ive only met her a few times. I dont know where she lives though.”
The social worker nodded like she already knew that. “Yes, were trying to locate her. Elen, right?” I nodded. “Is there anyone else?”
I hesitated, but then the words tumbled out before I could stop them. “Ive a sister.”
The air in the room changed. Mrs. Patterson, who had been standing quietly, stiffened, looking uncomfortable. The social worker raised an eyebrow, flicking through her papers. “A sister?” she repeated.
“Yes,” I said, sitting up straighter. “Mum told me. She was adopted at birth. Ive never met her, but Ive seen her. Once. Mum pointed her out when we were on the bus.”
Mrs. Patterson let out a small huff of breath. “Beth, your mum never mentioned a sister to me,” she said, shaking her head. “I think youre confused.”
Confused. Like I was a silly little girl making things up. I felt the anger rise in my chest, hot and sharp. “Im not confused,” I snapped, my voice trembling. “She exists.”
The social worker was watching me closely now, fingers laced together. “You think shes older than you?”
“She must be,” I said. “Mum never had another baby after me.”
Mrs. Patterson sighed, turning back to the social worker like I wasnt even there. “It doesnt sound right to me.”
“I know what my mum told me,” I hissed, gripping the edge of my seat. “Maybe I could go live with her?” My voice cracked slightly at the end. It wasnt just an idea, I was clinging to it, desperate for something that wasnt this.
The social worker gave me a patient, too patient, smile. “Well have to see if we can find her,” she said.
But the way she said it, the way she was already moving on, told me everything. She wasnt going to look. Not really. This was just something to say to keep me quiet.
Mrs. Patterson and the social worker turned back to their conversation, their voices dipping lower, the room tilting back to how it was before. Like I was just a problem to be handled. Like I wasnt even there.
I clenched my fists under the table, nails biting into my palms.
Fine, I thought. If they wont help me find her, Ill do it myself.
It was a few days after that, Mrs. Patterson came to me with a hesitant smile, wringing her hands.
“Your aunts been found, Beth,” she said, her voice soft, as if she didnt want to scare me. “Shes coming up from Devon. Shell look after you, love.”
An aunt. I barely knew her. I remembered her only in flashes from when I was small, a woman who didnt come around much, whod left her family far behind. But I clung to the thought of her like a lifeline. Anyone, anything, was better than what I had imagined.
She arrived the next day, a stern, quiet woman with lines on her face that didnt soften even when she looked at me. But I didnt care. She was family. And with her, maybe I could finish school, pretend life had some kind of normal left in it. I threw myself into my revision, shutting out the rest of the world. I studied until my head hurt, until the textbooks blurred in front of me, until all I could see were equations, lines of history, anything that drowned out the emptiness I felt.
But, one morning, I woke to silence.
She was gone. There was a note on the kitchen table, a scrawl of ink on cheap paper that made my stomach twist as I read it:
“I never wanted children, Beth. I still dont. Ive done what I could, but I cant stay. Youre on your own.”
Just like that, she was gone. No goodbye, no explanation. It was as if Id woken up into another nightmare, another life stripped away from me. I wanted to scream, to tear that note into a thousand pieces, but all I could do was stand there, numb, staring at the empty house around me. I was alone. Really, truly alone.
I wasnt sure how I got through the days after that. I told Mrs. Patterson that my aunt had gone out shopping or that she was at the doctors. I kept up the charade, as if pretending could somehow make it real. And each night, I lay in bed staring at the ceiling, counting down the hours until Id have to lie again. I kept going to school, pretending everything was fine, like I wasnt one wrong word away from losing it all.
But it couldnt last. One afternoon, after my exams were over, I saw them, social services, knocking on Mrs. Pattersons door. My heart plummeted. I knew what they were here for, theyd realised something was wrong, that my aunt wasnt really there, that I was living alone, a fifteen-year-old girl trying to hold up the crumbling walls of her life. I felt like the ground was shifting beneath me, like every step was about to give way.
That night, I packed a rucksack with the few things I couldnt leave behind. A few changes of clothes. An old biscuit tin with my mums things, photos, papers, the scraps of a life shed told me were important. Maybe there was something in there, some clue Id missed. Something that would lead me to her.
Grace.
I didnt even know if shed want me. She probably had a life of her own, a family, people who actually gave a shit. But she was all I had left, and I had to try. She had to be out there somewhere. Mum had seen her. From the bus. Which meant she was local.
I took what little money I had left, shoved it into my pocket, and with one last look at the house, I closed the door behind me. It felt final, like I was locking away a part of myself that Id never get back.
But I couldnt stay.
I wouldnt let them take me. Not after everything.
I slipped into the night, my rucksack heavy against my back. The streets stretched out before me, endless and uncertain, but I wasnt leaving just to disappear. I wasnt going far.
I was going to find my sister.

Some files were not shown because too many files have changed in this diff Show More