Save
This commit is contained in:
parent
1f429c7e83
commit
7d23a8e337
@ -43,9 +43,96 @@
|
||||
<Content Update="web.config">
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</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">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</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>
|
||||
@ -58,6 +145,7 @@
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<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="SixLabors.Fonts" Version="2.1.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
|
||||
|
||||
123
CatherineLynwood/Controllers/AudioController.cs
Normal file
123
CatherineLynwood/Controllers/AudioController.cs
Normal 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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -2,30 +2,70 @@
|
||||
|
||||
namespace CatherineLynwood.Controllers
|
||||
{
|
||||
[Route("the-alpha-flame/extras/discovery")]
|
||||
[Route("the-alpha-flame/discovery")]
|
||||
public class DiscoveryController : Controller
|
||||
{
|
||||
public IActionResult Index()
|
||||
#region Public Methods
|
||||
|
||||
[Route("chapters/chapter-1-beth")]
|
||||
public IActionResult Chapter1()
|
||||
{
|
||||
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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("maggies-designs")]
|
||||
public IActionResult MaggiesDesigns()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
#endregion Public Methods
|
||||
}
|
||||
}
|
||||
@ -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("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("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("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("Chapter2", "TheAlphaFlame", 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("Chapter1", "Discovery", 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", "Discovery", null, Request.Scheme).TrimEnd('/'), LastModified = DateTime.UtcNow },
|
||||
// Additional static pages
|
||||
};
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ namespace CatherineLynwood.Controllers
|
||||
return View();
|
||||
}
|
||||
|
||||
|
||||
[Route("blog")]
|
||||
public async Task<IActionResult> Blog(BlogFilter blogFilter)
|
||||
{
|
||||
@ -84,6 +85,12 @@ namespace CatherineLynwood.Controllers
|
||||
public async Task<IActionResult> BlogItem(string slug, bool showThanks)
|
||||
{
|
||||
Blog blog = await _dataAccess.GetBlogItemAsync(slug);
|
||||
|
||||
if (blog.Title == null)
|
||||
{
|
||||
return RedirectPermanent("/the-alpha-flame/blog");
|
||||
}
|
||||
|
||||
blog.ShowThanks = showThanks;
|
||||
|
||||
if (blog.Template == "slideshow")
|
||||
@ -94,22 +101,7 @@ namespace CatherineLynwood.Controllers
|
||||
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")]
|
||||
public IActionResult Characters()
|
||||
@ -166,19 +158,7 @@ namespace CatherineLynwood.Controllers
|
||||
return RedirectToAction("BlogItem", new { slug = blogUrl, showThanks = showThanks });
|
||||
}
|
||||
|
||||
[Route("discovery")]
|
||||
public IActionResult Discovery()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[BookAccess(1, 1)]
|
||||
[Route("extras")]
|
||||
public IActionResult Extras()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[Route("")]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View();
|
||||
|
||||
39
CatherineLynwood/Middleware/BotFilterMiddleware.cs
Normal file
39
CatherineLynwood/Middleware/BotFilterMiddleware.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -3,21 +3,29 @@
|
||||
public class RedirectToWwwMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private IWebHostEnvironment _environment;
|
||||
|
||||
public RedirectToWwwMiddleware(RequestDelegate next)
|
||||
public RedirectToWwwMiddleware(RequestDelegate next, IWebHostEnvironment environment)
|
||||
{
|
||||
_next = next;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
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}";
|
||||
context.Response.Redirect(newUrl, permanent: true);
|
||||
return; // End the middleware pipeline.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Continue to the next middleware.
|
||||
await _next(context);
|
||||
|
||||
23
CatherineLynwood/Models/ChapterAudioSegment.cs
Normal file
23
CatherineLynwood/Models/ChapterAudioSegment.cs
Normal 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
|
||||
}
|
||||
}
|
||||
13
CatherineLynwood/Models/ChapterPlayerViewModel.cs
Normal file
13
CatherineLynwood/Models/ChapterPlayerViewModel.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,12 @@ namespace CatherineLynwood
|
||||
// ✅ Register the book access code service
|
||||
builder.Services.AddScoped<IAccessCodeService, AccessCodeService>();
|
||||
|
||||
builder.Services.AddSingleton<ChapterAudioMapCache>();
|
||||
builder.Services.AddHostedService<ChapterAudioMapService>();
|
||||
builder.Services.AddSingleton<AudioTokenService>();
|
||||
|
||||
|
||||
|
||||
// Add response compression services
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
@ -77,6 +83,7 @@ namespace CatherineLynwood
|
||||
}
|
||||
|
||||
app.UseMiddleware<BlockPhpRequestsMiddleware>();
|
||||
app.UseMiddleware<BotFilterMiddleware>();
|
||||
app.UseMiddleware<RedirectToWwwMiddleware>();
|
||||
app.UseMiddleware<RefererValidationMiddleware>();
|
||||
app.UseMiddleware<HoneypotLoggingMiddleware>();
|
||||
|
||||
41
CatherineLynwood/Services/AudioTokenService.cs
Normal file
41
CatherineLynwood/Services/AudioTokenService.cs
Normal 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)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
29
CatherineLynwood/Services/ChapterAudioMapCache.cs
Normal file
29
CatherineLynwood/Services/ChapterAudioMapCache.cs
Normal 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
|
||||
}
|
||||
}
|
||||
130
CatherineLynwood/Services/ChapterAudioMapService.cs
Normal file
130
CatherineLynwood/Services/ChapterAudioMapService.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -1,36 +1,56 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
|
||||
namespace CatherineLynwood.TagHelpers
|
||||
{
|
||||
[HtmlTargetElement("MetaTag")]
|
||||
public class MetaTagHelper : TagHelper
|
||||
{
|
||||
// Shared 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; }
|
||||
#region Public Properties
|
||||
|
||||
// Open Graph specific properties
|
||||
public string OgSiteName { get; set; }
|
||||
public string OgType { get; set; } = "article";
|
||||
public DateTime? ArticleModifiedTime { get; set; }
|
||||
|
||||
// Article specific properties
|
||||
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
|
||||
public string TwitterCardType { get; set; } = "summary_large_image";
|
||||
public string TwitterSiteHandle { get; set; }
|
||||
|
||||
public string TwitterCreatorHandle { get; set; }
|
||||
public int? TwitterPlayerWidth { 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)
|
||||
{
|
||||
output.TagName = null; // Suppress output tag
|
||||
@ -79,55 +99,29 @@ namespace CatherineLynwood.TagHelpers
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
|
||||
if (!string.IsNullOrWhiteSpace(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)
|
||||
metaTags.AppendLine($"<meta name=\"twitter:player:width\" content=\"{TwitterPlayerWidth}\">");
|
||||
if (TwitterPlayerHeight.HasValue)
|
||||
metaTags.AppendLine($"<meta name=\"twitter:player:height\" content=\"{TwitterPlayerHeight}\">");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MetaImage))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
|
||||
}
|
||||
|
||||
// JSON-LD for schema.org
|
||||
var jsonLd = new
|
||||
else
|
||||
{
|
||||
@context = "https://schema.org",
|
||||
@type = "WebPage",
|
||||
name = MetaTitle,
|
||||
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>");
|
||||
metaTags.AppendLine($"<meta name=\"twitter:card\" content=\"{TwitterCardType}\">");
|
||||
if (!string.IsNullOrWhiteSpace(MetaImage))
|
||||
metaTags.AppendLine($"<meta name=\"twitter:image\" content=\"{MetaImage}\">");
|
||||
}
|
||||
|
||||
// Output all content
|
||||
output.Content.SetHtmlContent(metaTags.ToString());
|
||||
}
|
||||
|
||||
#endregion Public Methods
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,10 @@ namespace CatherineLynwood.TagHelpers
|
||||
[HtmlAttributeName("display-width-percentage")]
|
||||
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 _wwwRoot = "wwwroot";
|
||||
|
||||
@ -87,7 +91,10 @@ namespace CatherineLynwood.TagHelpers
|
||||
}
|
||||
|
||||
// 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}>");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -22,18 +22,18 @@ namespace CatherineLynwood.TagHelpers
|
||||
var shareHtml = $@"
|
||||
<div class='share-section'>
|
||||
<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='{twitterUrl}' target='_blank' rel='noopener' class='share-button twitter'><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='{pinterestUrl}' target='_blank' rel='noopener' class='share-button pinterest'><i class='fab fa-pinterest-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' aria-label='X share'><i class='fab fa-twitter'></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' aria-label='Pintesest share'><i class='fab fa-pinterest-square'></i></a>
|
||||
</div>";
|
||||
|
||||
var followHtml = @"
|
||||
<div class='follow-section mt-3'>
|
||||
<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://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.tiktok.com/@catherinelynwood' target='_blank' rel='noopener' class='follow-icon'><i class='fab fa-tiktok'></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' 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' aria-label='Follow on TikTok'><i class='fab fa-tiktok'></i></a>
|
||||
</div>";
|
||||
|
||||
output.TagName = "div";
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<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="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>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -77,6 +77,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
}
|
||||
|
||||
@section Meta {
|
||||
<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."
|
||||
@ -8,7 +8,7 @@
|
||||
<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="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>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -93,6 +93,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
}
|
||||
|
||||
@section Meta {
|
||||
<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."
|
||||
@ -8,7 +8,7 @@
|
||||
<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="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>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -76,6 +76,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
}
|
||||
|
||||
@section Meta {
|
||||
<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."
|
||||
@ -8,8 +8,8 @@
|
||||
<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="TheAlphaFlame" asp-action="Extras">Extras</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="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">Epilogue</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -26,12 +26,8 @@
|
||||
<!-- Audio and Text -->
|
||||
<div class="col-12">
|
||||
<div class="bg-white rounded-5 border border-3 border-dark shadow-lg p-3">
|
||||
<div class="hero-video-container">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
<!-- Audio Player -->
|
||||
<div class="audio-player text-center">
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
<ol class="breadcrumb">
|
||||
<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="Discovery" asp-action="Index">Discovery</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Extras</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@ -20,9 +21,11 @@
|
||||
<div class="container mt-5">
|
||||
<h1 class="extras-header">Your Exclusive Extras</h1>
|
||||
|
||||
@if (accessLevel >= 1)
|
||||
@if (accessBook == 1)
|
||||
{
|
||||
<div class="extras-grid mt-4">
|
||||
@if (accessLevel >= 1)
|
||||
{
|
||||
<div class="card extra-card">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
@if (accessLevel >= 2)
|
||||
{
|
||||
<div class="card extra-card">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
@if (accessLevel >= 3)
|
||||
{
|
||||
<div class="card extra-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Rubery Hill Photo Archive</h5>
|
||||
<p class="card-text">Explore historical photos and floor plans of the real Rubery Hill Hospital, the eerie inspiration behind key scenes.</p>
|
||||
<a href="/extras/rubery-hill-photos" class="btn btn-dark btn-sm">Explore</a>
|
||||
<h5 class="card-title">Listen to The Alpha Flame: Discovery</h5>
|
||||
<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 asp-controller="Discovery" asp-action="Listen" class="btn btn-dark btn-sm">Listen to the Book</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
@if (accessLevel >= 4)
|
||||
{
|
||||
<div class="card extra-card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Scrapbook: Maggie’s Designs</h5>
|
||||
@ -54,12 +63,29 @@
|
||||
<a asp-controller="Discovery" asp-action="MaggiesDesigns" class="btn btn-dark btn-sm">View Scrapbook</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (accessBook == 2)
|
||||
{
|
||||
|
||||
<div class="extras-grid mt-4">
|
||||
@if (accessLevel >= 1)
|
||||
{
|
||||
|
||||
}
|
||||
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-body">
|
||||
<h5 class="card-title">Epilogue</h5>
|
||||
@ -94,7 +120,7 @@
|
||||
|
||||
</div>
|
||||
}
|
||||
else if (accessLevel >= 3)
|
||||
else if (accessBook == 3)
|
||||
{
|
||||
<div class="extras-grid mt-4">
|
||||
<div class="card extra-card">
|
||||
@ -145,6 +145,11 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
|
||||
<script>
|
||||
fetch('https://ipapi.co/json/')
|
||||
.then(response => response.json())
|
||||
628
CatherineLynwood/Views/Discovery/Listen.cshtml
Normal file
628
CatherineLynwood/Views/Discovery/Listen.cshtml
Normal 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>
|
||||
}
|
||||
@ -7,8 +7,8 @@
|
||||
<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="TheAlphaFlame" asp-action="Extras">Extras</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="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">Maggie's Designs</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
<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="TheAlphaFlame" asp-action="Extras">Extras</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="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">Discovery Scrap Book</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
<div class="row align-items-center mb-5">
|
||||
<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">
|
||||
<video id="heroVideo" autoplay muted loop playsinline preload="none" poster="/images/webp/the-alpha-flame-discovery-blank-400.webp">
|
||||
<!-- Source will be injected later -->
|
||||
@ -25,10 +25,10 @@
|
||||
<responsive-image src="the-alpha-flame-discovery-overlay.png"
|
||||
class="hero-overlay-image"
|
||||
alt="The Alpha Flame: Discovery by Catherine Lynwood"
|
||||
display-width-percentage="50">
|
||||
display-width-percentage="50"
|
||||
no-index="true">
|
||||
</responsive-image>
|
||||
</div>
|
||||
|
||||
</a>
|
||||
</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...
|
||||
</p>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<a href="/the-alpha-flame/discovery" 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="Index" class="btn btn-dark">Explore the Book</a>
|
||||
<a asp-controller="Discovery" asp-action="Extras" class="btn btn-outline-dark">Unlock Extras</a>
|
||||
</div>
|
||||
<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.
|
||||
@ -53,11 +53,19 @@
|
||||
|
||||
|
||||
@section Meta {
|
||||
<meta name="description" content="Explore the captivating world of Catherine Lynwood’s *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 Lynwood’s storytelling and insights on the official website.">
|
||||
|
||||
<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, women’s novels, Catherine Lynwood blog, mystery novels for women, female protagonist book, family thriller">
|
||||
|
||||
<meta name="author" content="Catherine Lynwood">
|
||||
<MetaTag meta-title="Catherine Lynwood – Author of The Alpha Flame Trilogy"
|
||||
meta-description="Discover Catherine Lynwood’s gripping trilogy, *The Alpha Flame*, set in 1983 Birmingham. Follow the journey of two sisters as secrets unravel and destinies collide."
|
||||
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-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">
|
||||
{
|
||||
@ -65,7 +73,6 @@
|
||||
"@@type": "WebSite",
|
||||
"url": "https://www.catherinelynwood.com",
|
||||
"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": {
|
||||
"@@type": "Person",
|
||||
"name": "Catherine Lynwood"
|
||||
@ -73,15 +80,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Person -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "Person",
|
||||
"name": "Catherine Lynwood",
|
||||
"url": "https://www.catherinelynwood.com",
|
||||
"jobTitle": "Author",
|
||||
"knowsAbout": ["women’s fiction", "family secrets novels", "mystery"],
|
||||
"knowsLanguage": "English"
|
||||
"jobTitle": "Author"
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -134,6 +140,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => {
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
</section>
|
||||
|
||||
<div class="row justify-content-center p-3">
|
||||
<div class="col-auto">
|
||||
<div class="col-12 bg-white rounded-5">
|
||||
<audio controls>
|
||||
<source src="/audio/samantha-lynwood.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
@ -50,6 +50,12 @@
|
||||
|
||||
</div>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
}
|
||||
|
||||
@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 Catherine’s 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, women’s fiction author, mystery and romance novelist, family secrets book author, female protagonists in fiction, Catherine Lynwood novels, women’s novel writer, The Alpha Flame author, female fiction writers">
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<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/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>
|
||||
.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">
|
||||
<div class="container-fluid">
|
||||
<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>
|
||||
<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">
|
||||
@ -155,9 +155,9 @@
|
||||
<script src="~/js/plyr.js"></script>
|
||||
|
||||
|
||||
<script>
|
||||
@* <script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
</script> *@
|
||||
@RenderSection("Scripts", required: false)
|
||||
|
||||
<script>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<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>
|
||||
</header>
|
||||
</div>
|
||||
@ -35,7 +35,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(Model.AudioTranscriptUrl))
|
||||
{
|
||||
<div class="row justify-content-center p-3">
|
||||
<div class="col-auto">
|
||||
<div class="col-md-6 bg-white rounded-5">
|
||||
<audio controls>
|
||||
<source src="/audio/@Model.AudioTranscriptUrl" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
@ -120,6 +120,12 @@
|
||||
|
||||
</article>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
}
|
||||
|
||||
@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 Catherine’s journey as a writer, discover inspiration for her novels, and delve into discussions on women’s fiction, mystery, and family secrets.">
|
||||
<meta name="keywords" content="Catherine Lynwood blog, The Alpha Flame updates, women’s fiction blog, author Catherine Lynwood insights, mystery novel blog, family secrets stories, strong female protagonists, 1980s fiction blog, book updates and stories, author’s journey blog, Catherine Lynwood writing process">
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="container py-2">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-5 fw-bold">The Alpha Flame Trilogy</h1>
|
||||
<p class="lead">
|
||||
@ -69,9 +69,6 @@
|
||||
<div class="col-12">
|
||||
<div class="card h-100 shadow-sm border-start border-3 border-dark position-relative">
|
||||
<div class="row g-0">
|
||||
<a asp-action="Characters">
|
||||
|
||||
</a>
|
||||
<div class="col-md-8 d-none d-md-block">
|
||||
<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>
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(Model.AudioTranscriptUrl))
|
||||
{
|
||||
<div class="row justify-content-center p-3">
|
||||
<div class="col-auto">
|
||||
<div class="col-md-6 bg-white rounded-5">
|
||||
<audio controls>
|
||||
<source src="/audio/@Model.AudioTranscriptUrl" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
@ -142,6 +142,12 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const player = new Plyr('audio');
|
||||
</script>
|
||||
}
|
||||
|
||||
@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 Catherine’s journey as a writer, discover inspiration for her novels, and delve into discussions on women’s fiction, mystery, and family secrets.">
|
||||
<meta name="keywords" content="Catherine Lynwood blog, The Alpha Flame updates, women’s fiction blog, author Catherine Lynwood insights, mystery novel blog, family secrets stories, strong female protagonists, 1980s fiction blog, book updates and stories, author’s journey blog, Catherine Lynwood writing process">
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<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="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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,10 @@
|
||||
"Email": {
|
||||
"APIKey": "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"
|
||||
},
|
||||
"AudioSecurity": {
|
||||
"HmacSecretKey": "SG.7xaVKHzRQsS5os1IJUJZ2Q.2osFDJIRkjlDl3eM05uZ9R1IUA6Wv",
|
||||
"TokenExpirySeconds": 86400
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@ -52,5 +52,11 @@
|
||||
"inputFiles": [
|
||||
"wwwroot/js/plyr.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/css/plyr.min.css",
|
||||
"inputFiles": [
|
||||
"wwwroot/css/plyr.css"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,64 @@
|
||||
The Alpha Flame, by Catherine Lynwood
|
||||
|
||||
Chapter 1 - Drowning in Silence
|
||||
|
||||
Narrated by Beth – 7th May 1980
|
||||
|
||||
I’d 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 didn’t 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 doll’s, skin washed out, lifeless. The first thought I had, the thing I’ll never forgive myself for, was how wrong it looked. It felt surreal, like some sort of sick joke. This wasn’t her. It couldn’t be. My mum wasn’t 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. She’d 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 couldn’t 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 they’re wrong. I think it’s 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 she’d 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.
|
||||
There’s something that breaks in you when you lose everything in one heartbeat. It’s like the walls inside you just give way, crumbling into nothing, until all that’s 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 couldn’t look away from her. I couldn’t move, couldn’t breathe. I was frozen, trapped in this nightmare that wouldn’t end, a part of me hoping that if I stared long enough, I’d wake up. That this would all just go away.
|
||||
But it didn’t. And I knew it wouldn’t. 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 wouldn’t move. I was terrified, terrified that if I touched her, it would make it real. I’d have to face it, that she was gone. That she’d 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 don’t 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 who’d ever loved me.
|
||||
And then, the silence broke. It shattered around me, replaced by this horrible, waling sound that I didn’t 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 I’d never cried before. It was like every ounce of pain I’d ever felt, every lonely night, every broken promise she’d left me with was pouring out, raw and brutal and endless.
|
||||
I don’t remember deciding to leave the bathroom. I don’t remember making the choice to move. But somehow, my feet carried me forward, step after step, like I wasn’t really there at all. Like my body knew what to do even when my mind didn’t. 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. I’d 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. What’s wrong?”
|
||||
As hard as I tried, I couldn’t 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? What’s wrong?” she persisted.
|
||||
I just looked at her blankly, and mumbled, “Mum.”
|
||||
What happened after that I couldn’t 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 mum’s 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. Patterson’s house next door, floating in a fog that didn’t 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 wasn’t 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, “There’s no father in the picture. We’re looking at… alternative options.”
|
||||
Alternative options… They spoke in circles around the word care; a word that didn’t mean what they thought it did. I’d heard the stories at school, kids who disappeared into those places, came out harder, colder, or didn’t 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 didn’t have any say in it.
|
||||
I remember sitting there, hunched on Mrs. Patterson’s 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, “I’ve got an aunt somewhere. I’ve only met her a few times. I don’t know where she lives though.”
|
||||
The social worker nodded like she already knew that. “Yes, we’re 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. “I’ve 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. I’ve never met her, but I’ve 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 you’re confused.”
|
||||
Confused. Like I was a silly little girl making things up. I felt the anger rise in my chest, hot and sharp. “I’m not confused,” I snapped, my voice trembling. “She exists.”
|
||||
The social worker was watching me closely now, fingers laced together. “You think she’s 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 wasn’t even there. “It doesn’t 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 wasn’t just an idea, I was clinging to it, desperate for something that wasn’t this.
|
||||
The social worker gave me a patient, too patient, smile. “We’ll 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 wasn’t 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 wasn’t even there.
|
||||
I clenched my fists under the table, nails biting into my palms.
|
||||
Fine, I thought. If they won’t help me find her, I’ll do it myself.
|
||||
It was a few days after that, Mrs. Patterson came to me with a hesitant smile, wringing her hands.
|
||||
“Your aunt’s been found, Beth,” she said, her voice soft, as if she didn’t want to scare me. “She’s coming up from Devon. She’ll look after you, love.”
|
||||
An aunt. I barely knew her. I remembered her only in flashes from when I was small, a woman who didn’t come around much, who’d 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 didn’t soften even when she looked at me. But I didn’t 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 don’t. I’ve done what I could, but I can’t stay. You’re on your own.”
|
||||
Just like that, she was gone. No goodbye, no explanation. It was as if I’d 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 wasn’t 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 doctor’s. 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 I’d have to lie again. I kept going to school, pretending everything was fine, like I wasn’t one wrong word away from losing it all.
|
||||
But it couldn’t last. One afternoon, after my exams were over, I saw them, social services, knocking on Mrs. Patterson’s door. My heart plummeted. I knew what they were here for, they’d realised something was wrong, that my aunt wasn’t 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 couldn’t leave behind. A few changes of clothes. An old biscuit tin with my mum’s things, photos, papers, the scraps of a life she’d told me were important. Maybe there was something in there, some clue I’d missed. Something that would lead me to her.
|
||||
Grace.
|
||||
I didn’t even know if she’d 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 I’d never get back.
|
||||
But I couldn’t stay.
|
||||
I wouldn’t 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 wasn’t leaving just to disappear. I wasn’t going far.
|
||||
I was going to find my sister.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user